import {
    BadRequestError,
    BaseAdapter,
    ErrorCodes,
    ForbiddenError,
    LoggerPort,
    NotFoundError,
    QueueMessageType,
    QueueMessageTypes,
    allPromises,
    roundNumber,
} from '@humand-packages/common';
import { LimitOffsetPagination } from 'humand-packages/monolith/src/api/types/listOptions';
import { Interval } from 'luxon';
import { Service } from 'typedi';
import { v7 as uuidv7 } from 'uuid';
import { PortsDI } from '../../../../config/portsDI';
import { AttachmentsService } from '../../../attachments/business/services/attachmentsService';
import { Instance } from '../../../instances/business/models/instance';
import { InstancesService } from '../../../instances/business/services/instancesService';
import { SegmentationsRepositoryPort } from '../../../segmentations/business/ports/segmentationsRepositoryPort';
import { UserInterface } from '../../../users/business/models/userInterface';
import { userDAOtoUser } from '../../../users/infrastructure/mappers/usersMapper';
import { TimeTrackingCategorizedHourStatus } from '../../infrastructure/constants/timeTracking/categorizedHourStatus';
import {
    mapCreateHourCategoryVCToDTO,
    mapCategorizedHoursWithUserEnrichment,
    mapUpdateHourCategoryVCToDTO,
    mapUsersToUserBasicDataDTOs,
    UserBasicDataDTO,
} from '../../infrastructure/mappers/timeTracking/timeTrackingHourCategorizationMapper';
import { TimeTrackingHourCategorizationRepositoryPort } from '../../infrastructure/ports/timeTracking/timeTrackingHourCategorizationRepositoryPort';
import { CreateHourCategoryVC } from '../../presentation/validationClasses/hourCategorization/createHourCategoryVC';
import { GetCategorizedHoursVC } from '../../presentation/validationClasses/hourCategorization/getCategorizedHoursVC';
import { UpdateCategorizedHourVC } from '../../presentation/validationClasses/hourCategorization/updateCategorizedHourVC';
import { UpdateHourCategoryVC } from '../../presentation/validationClasses/hourCategorization/updateHourCategoryVC';
import { CategorizedHoursActionType } from '../constants/categorizedHoursActionTypes';
import { TimeTrackingDaySummary } from '../models/daySummary';
import { HourCategory, HourCategoryForCreation, HourCategoryForUpdate } from '../models/hourCategorization';
import { PairedEntry } from '../models/PairedEntry';
import { CategorizationExplanation } from '../models/timeTracking/categorizationExplanation';
import { HOUR_CATEGORIZATION_TRACE_MESSAGE, TraceScope } from '../models/timeTracking/categorizationTrace';
import { CategorizedHourCategoryCount } from '../models/timeTracking/categorizedHourCategoryCount';
import { CategorizedHoursQueryScope } from '../models/timeTracking/categorizedHoursQueryScope';
import { CategoryNode, CategorySkippedNode, RuleAstNode, RunNode } from '../models/timeTracking/ruleAst';
import {
    TimeTrackingCategorizedHourForCreation,
    TimeTrackingCategorizedHourForDaySummary,
    TimeTrackingCategorizedHourWithCategoryData,
    TimeTrackingCategorizedHourWithWorkData,
    TimeTrackingCategorizedHourWithUserData,
} from '../models/timeTracking/timeTrackingCategorizedHour';
import { TimeTrackingEntryBasicData } from '../models/timeTrackingEntry';
import { TimeTrackingPolicyAssociation } from '../models/timeTrackingPolicy';
import { TimeTrackingPolicyHourCategory } from '../models/TimeTrackingPolicyHourCategory';
import ScheduleCategorizedHoursPort from '../ports/scheduleCategorizedHoursPort';
import TimeTrackingNotificationsPort from '../ports/timeTrackingNotificationsPort';
import { TimeTrackingPoliciesPort } from '../ports/timeTrackingPoliciesPort';
import { UsersServicePort } from '../ports/userServicePort';
import { CategorizedHoursFilters } from '../types/hourCategorization/categorizedHoursFilters';
import { buildRuleAst } from '../utils/ruleAstBuilder';
import { executeRuleAst } from '../utils/ruleAstExecutor';
import { pairedEntriesToIntervals, totalDurationInHours } from '../utils/timeUtils';
import { buildCategoryInput, intervalsToTuples } from '../utils/traceBuilder';
import TimeTrackingService from './timeTracking';
import { TimeTrackingCategorizedHourApprovalService } from './timeTracking/timeTrackingCategorizedHourApprovalService';

@Service()
export class TimeTrackingHourCategorizationService extends BaseAdapter {
    constructor(
        @PortsDI(TimeTrackingHourCategorizationRepositoryPort)
        private readonly timeTrackingHourCategorizationRepository: TimeTrackingHourCategorizationRepositoryPort,
        @PortsDI(TimeTrackingPoliciesPort)
        private readonly timeTrackingPoliciesService: TimeTrackingPoliciesPort,
        @PortsDI(TimeTrackingService) private readonly timeTrackingService: TimeTrackingService,
        @PortsDI(UsersServicePort) private readonly usersServicePort: UsersServicePort,
        @PortsDI(SegmentationsRepositoryPort) private readonly segmentationsRepository: SegmentationsRepositoryPort,
        private readonly instancesService: InstancesService,
        private readonly attachmentsService: AttachmentsService,
        private readonly logger: LoggerPort,
        @PortsDI(ScheduleCategorizedHoursPort) private readonly scheduleCategorizedHoursPort: ScheduleCategorizedHoursPort,
        private readonly categorizedHourApprovalService: TimeTrackingCategorizedHourApprovalService,
        @PortsDI(TimeTrackingNotificationsPort)
        private readonly timeTrackingNotificationsPort: TimeTrackingNotificationsPort,
    ) {
        super();
    }

    /**
     * List all hour categories
     * @param instanceId - The instance id
     * @param pagination - The pagination options
     * @param targetDateString - Optional date to filter categories where effectiveFrom <= targetDateString
     * @param search - Optional search string to filter categories by name
     * @returns The hour categories
     */
    public async listHourCategories(
        instanceId: number,
        pagination: LimitOffsetPagination,
        targetDateString?: string,
        search?: string,
    ): Promise<{ count: number; rows: HourCategory[] }> {
        const result = await this.timeTrackingHourCategorizationRepository.listHourCategories(instanceId, pagination, targetDateString, search);
        return {
            count: result.count,
            rows: result.rows,
        };
    }

    public async getHourCategory(instanceId: number, id: number): Promise<HourCategory> {
        return await this.timeTrackingHourCategorizationRepository.getHourCategory(instanceId, id);
    }

    public async createHourCategory(instanceId: number, hourCategory: CreateHourCategoryVC): Promise<HourCategory> {
        const hourCategoryForCreation: HourCategoryForCreation = mapCreateHourCategoryVCToDTO(hourCategory, instanceId);

        return await this.timeTrackingHourCategorizationRepository.createHourCategory(instanceId, hourCategoryForCreation);
    }

    public async updateHourCategory(instanceId: number, id: number, hourCategory: UpdateHourCategoryVC): Promise<HourCategory> {
        const hourCategoryForUpdate: HourCategoryForUpdate = mapUpdateHourCategoryVCToDTO(hourCategory, instanceId);
        const result = await this.timeTrackingHourCategorizationRepository.updateHourCategory(instanceId, id, hourCategoryForUpdate);
        await this.scheduleCategorizedHoursPort.schedule({
            payload: { hourCategoryId: id, instanceId },
            type: CategorizedHoursActionType.HOUR_CATEGORY_CHANGE,
        });
        return result;
    }

    public async deleteHourCategory(instanceId: number, id: number): Promise<void> {
        await this.scheduleCategorizedHoursPort.schedule({
            payload: { hourCategoryId: id, instanceId },
            type: CategorizedHoursActionType.HOUR_CATEGORY_DELETION,
        });
        return await this.timeTrackingHourCategorizationRepository.deleteHourCategory(instanceId, id);
    }

    public async listHourCategoriesForPolicy(
        instanceId: number,
        policyId: number,
        pagination: LimitOffsetPagination,
        search?: string,
    ): Promise<{ count: number; rows: Array<TimeTrackingPolicyHourCategory> }> {
        return await this.timeTrackingHourCategorizationRepository.listHourCategoriesForPolicy(instanceId, policyId, pagination, search);
    }

    public async getEnabledCategoryIdsForPolicies(policyIds: number[], instanceId: number, targetDate?: string): Promise<Set<number>> {
        if (policyIds.length === 0) return new Set();
        const policyHourCategories = await this.timeTrackingHourCategorizationRepository.getPolicyHourCategoriesForPolicyIds(
            policyIds,
            instanceId,
            targetDate,
        );
        return new Set(policyHourCategories.map((phc) => phc.category.id));
    }

    public async getDistinctApprovedCategoryIds(instanceId: number, userIds: number[], fromDate: string, toDate: string): Promise<Set<number>> {
        return await this.timeTrackingHourCategorizationRepository.getDistinctApprovedCategoryIds(instanceId, userIds, fromDate, toDate);
    }

    // When removed, also update and remove TimeTrackingCategorizedHourForDaySummary. Replaced by getApprovedCategorizedHoursForDaySummaries.
    public async getCategorizedHoursForDaySummaries(
        daySummaries: TimeTrackingDaySummary[],
        instance: Instance,
        startDate: string,
    ): Promise<Map<number, TimeTrackingCategorizedHourForDaySummary[]>> {
        if (daySummaries.length === 0) {
            return new Map<number, TimeTrackingCategorizedHourForDaySummary[]>();
        }

        const hourCategories = await this.timeTrackingHourCategorizationRepository.listHourCategories(
            instance.getId(),
            {
                limit: 100,
                offset: 0,
            },
            startDate,
        );

        if (hourCategories.count === 0) {
            return new Map<number, TimeTrackingCategorizedHourForDaySummary[]>();
        }

        const categoryNameById = new Map<number, string>();
        hourCategories.rows.forEach((c) => categoryNameById.set(c.id, c.name));

        const categorizedHoursMap = new Map<number, TimeTrackingCategorizedHourForDaySummary[]>();

        for (const daySummary of daySummaries) {
            const raw = this.calculateCategorizedHoursFromCategories(hourCategories.rows, daySummary, instance.getTimezone());
            const categorizedHours: TimeTrackingCategorizedHourForDaySummary[] = raw.map(
                (ch) =>
                    new TimeTrackingCategorizedHourForDaySummary({
                        category: { name: categoryNameById.get(ch.hourCategoryId) ?? '' },
                        hours: ch.hours,
                    }),
            );
            categorizedHoursMap.set(daySummary.id, categorizedHours);
        }

        return categorizedHoursMap;
    }

    public calculateCategorizedHoursFromCategories(
        categories: HourCategory[],
        daySummary: TimeTrackingDaySummary,
        timezone: string,
    ): TimeTrackingCategorizedHourForCreation[] {
        const categorizedHours: TimeTrackingCategorizedHourForCreation[] = [];

        for (const category of categories) {
            const hours = this.calculateCategoryHours(category, daySummary, timezone);

            if (hours <= 0) {
                continue;
            }
            categorizedHours.push(
                new TimeTrackingCategorizedHourForCreation({
                    dateString: daySummary.dateString,
                    hourCategoryId: category.id,
                    hours,
                    instanceId: daySummary.instanceId,
                    status: TimeTrackingCategorizedHourStatus.AUTO_APPROVED,
                    userId: daySummary.userId,
                }),
            );
        }

        return categorizedHours;
    }

    public async getPendingCategorizedHoursAmount(instanceId: number, loggedUserId: number): Promise<number> {
        return await this.timeTrackingHourCategorizationRepository.getPendingCategorizedHoursAmount(instanceId, loggedUserId);
    }

    /**
     * Get a categorized hour with work data
     * Includes work time slots and entries for the day summary of the categorized hour
     * @param instance - The instance
     * @param categorizedHourId - The categorized hour id
     * @param hasManageCapability - Whether the user has the manage capability
     * @param userId - The user id requesting the categorized hour
     * @returns The categorized hour with work data
     */
    public async getCategorizedHour(
        instance: Instance,
        categorizedHourId: string,
        hasManageCapability: boolean,
        userId: number,
    ): Promise<TimeTrackingCategorizedHourWithWorkData> {
        // Get categorized hour by id
        const categorizedHour = await this.timeTrackingHourCategorizationRepository.getCategorizedHour(instance.getId(), categorizedHourId);
        if (!categorizedHour) {
            throw new NotFoundError('Categorized hour not found', ErrorCodes.TIME_TRACKING_CATEGORIZED_HOUR_NOT_FOUND);
        }
        if (!hasManageCapability) {
            const approverHourIds = await this.categorizedHourApprovalService.getCategorizedHourIdsForApprover(userId, instance.getId());
            if (!approverHourIds.includes(categorizedHourId)) {
                throw new ForbiddenError('You do not have access to this categorized hour', ErrorCodes.ACCESS_DENIED);
            }
        }

        // Fetch user data and current approval step ID in parallel
        const [userDAO, currentStepIdsByHourId] = await Promise.all([
            this.usersServicePort.getByIdOrFail(instance.getId(), categorizedHour.userId),
            this.categorizedHourApprovalService.getCurrentStepIdsByCategorizedHourIds([categorizedHourId]),
        ]);
        const loggedUserApproverHourIds = await this.getLoggedUserApproverHourIds(currentStepIdsByHourId, userId);
        const [categorizedHourWithUser] = mapCategorizedHoursWithUserEnrichment(
            [categorizedHour],
            mapUsersToUserBasicDataDTOs([userDAO]),
            currentStepIdsByHourId,
            loggedUserApproverHourIds,
        );

        // Adds entries and work time slots to the categorized hour
        const userDaySummaries = await this.timeTrackingService.getOrCreateDaySummariesByUser(
            categorizedHour.userId,
            instance,
            categorizedHour.dateString,
            categorizedHour.dateString,
            true,
        );
        if (!userDaySummaries.length) {
            throw new NotFoundError('User day summary not found', ErrorCodes.TIME_TRACKING_DAY_SUMMARY_NOT_FOUND);
        }
        const userDaySummary = userDaySummaries[0];

        const workTimeSlots = userDaySummary.timeSlots ?? [];
        const entries = userDaySummary.timeTrackingEntries.map(
            (entry) =>
                new TimeTrackingEntryBasicData({
                    id: entry.id,
                    time: entry.time,
                    type: entry.type,
                }),
        );

        await this.attachmentsService.addSignedUrlToModelWithImages(instance.getId(), [categorizedHourWithUser], ['profilePicture']);

        const result = new TimeTrackingCategorizedHourWithWorkData({
            ...categorizedHourWithUser,
            entries,
            workTimeSlots,
        });

        if (result.statusOverriddenByAdminId) {
            const adminDAO = await this.usersServicePort.getByIdOrNull(instance.getId(), result.statusOverriddenByAdminId);
            if (adminDAO) {
                await this.attachmentsService.addSignedUrlToModelWithImages(instance.getId(), [adminDAO], ['profilePicture']);
                result.statusOverriddenByAdmin = userDAOtoUser(adminDAO);
            } else {
                result.statusOverriddenByAdmin = null;
            }
        }

        return result;
    }

    /**
     * Recomputes hour categorization for a single (user, date) and returns the AST of how
     * every category was evaluated, alongside the currently stored categorized hours.
     *
     * Read-only and idempotent — does not persist trace events or recomputed values. The
     * stored hours may diverge from the recomputed values when entries have been edited or
     * an admin has overridden a status; the response includes both so the UI can display
     * the delta.
     */
    public async explainCategorization(
        instance: Instance,
        userId: number,
        dateString: string,
        requesterId: number,
        hasManageCapability: boolean,
    ): Promise<CategorizationExplanation> {
        if (!hasManageCapability && requesterId !== userId) {
            throw new ForbiddenError('You can only explain your own categorization', ErrorCodes.ACCESS_DENIED);
        }
        // Multi-tenant precondition: closes the door upfront so downstream refactors can't
        // leak via userId-only queries (e.g. `getOrCreateDaySummariesByUser`'s repo).
        await this.usersServicePort.getByIdOrFail(instance.getId(), userId);

        const associations = await this.timeTrackingPoliciesService.listActivePolicyAssociations(instance.getId(), [userId]);
        const policyId = associations[0]?.policy?.id ?? null;

        const runScope: TraceScope = { instanceId: instance.getId() };
        const startedAt = new Date().toISOString();
        const children: (CategoryNode | CategorySkippedNode)[] = [];

        if (policyId !== null) {
            const policyHourCategories = await this.timeTrackingHourCategorizationRepository.getPolicyHourCategoriesForPolicyIds(
                [policyId],
                instance.getId(),
                dateString,
            );
            if (policyHourCategories.length > 0) {
                const [daySummary] = await this.timeTrackingService.getOrCreateDaySummariesByUser(userId, instance, dateString, dateString);
                children.push(...this.buildDayCategoryNodes(daySummary, policyHourCategories, instance.getTimezone()));
            }
        }

        const run: RunNode = { type: 'RUN', scope: runScope, startedAt, completedAt: new Date().toISOString(), children };

        const categorizedHours = await this.timeTrackingHourCategorizationRepository.getCategorizedHoursForUserDate(
            instance.getId(),
            userId,
            dateString,
        );

        return new CategorizationExplanation({
            run,
            categorizedHours,
            computedAt: new Date(),
        });
    }

    /**
     * Builds the per-day `CATEGORY` / `CATEGORY_SKIPPED` nodes (each `CATEGORY` carrying its rules'
     * executed, annotated ASTs). Shared by the explain endpoint and the hot-path log-trace — the
     * single source of the per-day evaluation tree, so the two can never drift.
     */
    private buildDayCategoryNodes(
        daySummary: TimeTrackingDaySummary,
        policyHourCategories: Array<{ category: HourCategory; requiresApproval: boolean }>,
        timezone: string,
    ): (CategoryNode | CategorySkippedNode)[] {
        const entryIds = (daySummary.timeTrackingEntries ?? []).map((e) => e.id);
        const workedIntervals = pairedEntriesToIntervals(PairedEntry.getPairedResults(daySummary.timeTrackingEntries || []), timezone);
        const hasActivity = entryIds.length > 0 || (daySummary.timeOffHours ?? 0) > 0;

        const scopeFor = (hourCategoryId: number): TraceScope => ({
            instanceId: daySummary.instanceId,
            userId: daySummary.userId,
            dateString: daySummary.dateString,
            hourCategoryId,
        });

        if (!hasActivity) {
            return policyHourCategories.map(({ category }) => ({
                type: 'CATEGORY_SKIPPED' as const,
                scope: scopeFor(category.id),
                reason: 'NO_ACTIVITY' as const,
            }));
        }

        const categoryInput = buildCategoryInput(daySummary, workedIntervals, timezone);
        return policyHourCategories.map(({ category }) => {
            const { hours, mergedIntervals, ruleNodes } = this.evaluateCategory(category, daySummary, timezone, true);
            return {
                type: 'CATEGORY' as const,
                scope: scopeFor(category.id),
                input: categoryInput,
                totalHours: hours,
                mergedIntervals: intervalsToTuples(mergedIntervals, timezone),
                rules: ruleNodes,
            };
        });
    }

    public async updateCategorizedHour(
        instance: Instance,
        categorizedHourId: string,
        params: UpdateCategorizedHourVC,
        hasManageCapability: boolean,
        userId: number,
    ): Promise<TimeTrackingCategorizedHourWithWorkData> {
        const categorizedHour = await this.getCategorizedHour(instance, categorizedHourId, hasManageCapability, userId);

        if (params.status) {
            this.validateStatusTransition(categorizedHour.status, params.status);
        }

        const updatedCategorizedHour = await this.timeTrackingHourCategorizationRepository.updateCategorizedHour(
            instance.getId(),
            categorizedHourId,
            params,
        );

        // TO REMOVE WHEN APPROVAL STEPS ARE IMPLEMENTED
        this.logger.debug('Categorized hour updated', {
            categorizedHourId,
            changedByUserId: userId,
            newNote: updatedCategorizedHour.note ?? null,
            newStatus: updatedCategorizedHour.status,
            oldNote: categorizedHour.note ?? null,
            oldStatus: categorizedHour.status,
        });

        // Enriches updated categorized hour with work and user data
        return new TimeTrackingCategorizedHourWithWorkData({
            ...updatedCategorizedHour,
            currentStepId: categorizedHour.currentStepId,
            employeeInternalId: categorizedHour.employeeInternalId,
            entries: categorizedHour.entries,
            fullName: categorizedHour.fullName,
            hourCategoryName: categorizedHour.hourCategoryName,
            isLoggedUserPotentialApprover: categorizedHour.isLoggedUserPotentialApprover,
            isStatusOverridden: updatedCategorizedHour.statusOverriddenByAdminId != null,
            profilePicture: categorizedHour.profilePicture,
            workTimeSlots: categorizedHour.workTimeSlots,
        });
    }

    public async listCategorizedHours(
        instance: Instance,
        pagination: LimitOffsetPagination,
        filters: GetCategorizedHoursVC,
        user: UserInterface,
        hasManageCapability: boolean,
    ): Promise<{ count: number; rows: TimeTrackingCategorizedHourWithUserData[] }> {
        const scope = await this.resolveCategorizedHoursQueryScope(instance, filters, user, hasManageCapability);
        if (!scope) {
            return { count: 0, rows: [] };
        }
        const queryFilters = { ...scope.queryFilters, sortBy: filters.sortBy, sortOrder: filters.sortOrder } as GetCategorizedHoursVC;
        const result = await this.timeTrackingHourCategorizationRepository.listCategorizedHours(
            instance.getId(),
            pagination,
            queryFilters,
            user.getId(),
            hasManageCapability,
            scope.searchedUserIds,
            scope.approverCategorizedHourIds,
        );

        const uniqueUserIds = [...new Set(result.rows.map((r) => r.userId))];
        const categorizedHourIds = result.rows.map((r) => r.id);

        const [userRows, currentStepIdsByHourId] = await Promise.all([
            (async (): Promise<UserBasicDataDTO[]> => {
                if (uniqueUserIds.length === 0) return [];
                const usersResult = await this.usersServicePort.listUsers(
                    user,
                    { ids: uniqueUserIds },
                    { pagination: { limit: uniqueUserIds.length, offset: 0 }, sortingOptions: [] },
                    { excludeLoggedUser: false },
                );
                if (usersResult.rows.length === 0) return [];
                await this.attachmentsService.addSignedUrlToModelWithImages(instance.getId(), usersResult.rows, ['profilePicture']);
                return mapUsersToUserBasicDataDTOs(usersResult.rows);
            })(),
            this.categorizedHourApprovalService.getCurrentStepIdsByCategorizedHourIds(categorizedHourIds),
        ]);

        // Determine which of the current page's active (PENDING) steps the logged user can approve.
        const loggedUserApproverHourIds = await this.getLoggedUserApproverHourIds(currentStepIdsByHourId, user.getId());

        // Enriches categorized hour rows with user data, current approval step IDs, and approver flag.
        const rows = mapCategorizedHoursWithUserEnrichment(result.rows, userRows, currentStepIdsByHourId, loggedUserApproverHourIds);

        return { count: result.count, rows };
    }

    public async countCategorizedHoursByCategory(
        instance: Instance,
        filters: CategorizedHoursFilters,
        user: UserInterface,
        hasManageCapability: boolean,
    ): Promise<{ rows: CategorizedHourCategoryCount[]; total: number }> {
        const scope = await this.resolveCategorizedHoursQueryScope(instance, filters, user, hasManageCapability);
        if (!scope) return { rows: [], total: 0 };
        const rows = await this.timeTrackingHourCategorizationRepository.countCategorizedHoursByCategory({
            instanceId: instance.getId(),
            filters: scope.queryFilters,
            loggedUserId: user.getId(),
            hasManageCapability,
            searchedUserIds: scope.searchedUserIds,
            approverCategorizedHourIds: scope.approverCategorizedHourIds,
        });
        const total = rows.reduce((acc, r) => acc + r.count, 0);
        return { rows, total };
    }

    /**
     * Resolves the user-scope inputs shared by listCategorizedHours and countCategorizedHoursByCategory.
     */
    private async resolveCategorizedHoursQueryScope(
        instance: Instance,
        filters: CategorizedHoursFilters,
        user: UserInterface,
        hasManageCapability: boolean,
    ): Promise<CategorizedHoursQueryScope | null> {
        const searchedUserIds =
            filters.search !== undefined ? await this.usersServicePort.getUserIds(user, { search: filters.search }, hasManageCapability) : undefined;
        const filteredUserIds = await this.getFilteredUserIds(filters);
        const approverCategorizedHourIds = hasManageCapability
            ? undefined
            : await this.categorizedHourApprovalService.getCategorizedHourIdsForApprover(user.getId(), instance.getId());

        if ((filteredUserIds?.length ?? 0) === 0 && !filters.search && !approverCategorizedHourIds?.length) {
            return null;
        }

        return new CategorizedHoursQueryScope({
            searchedUserIds,
            approverCategorizedHourIds,
            queryFilters: { ...filters, userIds: filteredUserIds },
        });
    }

    /**
     * Returns the set of categorized hour IDs from currentStepIdsByHourId where
     * the given userId is a potential approver of the current PENDING step.
     */
    private async getLoggedUserApproverHourIds(currentStepIdsByHourId: Map<string, string>, userId: number): Promise<Set<string>> {
        const currentStepIds = [...currentStepIdsByHourId.values()];
        if (currentStepIds.length === 0) return new Set();
        const approverStepIds = new Set(await this.categorizedHourApprovalService.getStepIdsWhereUserIsPotentialApprover(userId, currentStepIds));
        return new Set([...currentStepIdsByHourId.entries()].filter(([, stepId]) => approverStepIds.has(stepId)).map(([hourId]) => hourId));
    }

    private intersectUserIds(a: number[], b: number[]): number[] {
        const bSet = new Set(b);
        return a.filter((id) => bSet.has(id));
    }

    /**
     * userIds AND (segmentations when itemIds)
     * Undefined initial userIds means "no restriction" for that step.
     */
    private async getFilteredUserIds(filters: CategorizedHoursFilters): Promise<number[] | undefined> {
        const hasItemIds = !!filters.itemIds?.length;

        let resultUserIds: number[] | undefined = filters.userIds;

        if (hasItemIds) {
            const segmentationUserIds = await this.segmentationsRepository.getUserIdsBySegmentationItemIds(filters.itemIds!, true, resultUserIds);
            resultUserIds = resultUserIds ? this.intersectUserIds(resultUserIds, segmentationUserIds) : segmentationUserIds;
        }

        return resultUserIds;
    }

    public calculateCategoryHours(category: HourCategory, daySummary: TimeTrackingDaySummary, timezone: string): number {
        return this.evaluateCategory(category, daySummary, timezone).hours;
    }

    /**
     * Evaluates a category for a day: builds and executes each rule's AST (literal-order engine),
     * merges the qualifying intervals into total hours, and returns each rule's executed AST node
     * for the explain / log-trace. `annotate` decorates the AST nodes with their per-stage results;
     * the calculation path leaves it off (it only needs the merged hours).
     */
    private evaluateCategory(
        category: HourCategory,
        daySummary: TimeTrackingDaySummary,
        timezone: string,
        annotate = false,
    ): { hours: number; mergedIntervals: Interval[]; ruleNodes: RuleAstNode[] } {
        const workedIntervals = pairedEntriesToIntervals(PairedEntry.getPairedResults(daySummary.timeTrackingEntries || []), timezone);
        const allIntervals: Interval[] = [];
        const ruleNodes: RuleAstNode[] = [];

        for (const rule of category.rules) {
            const { intervals, annotatedNode } = executeRuleAst(buildRuleAst(rule), daySummary, workedIntervals, timezone, annotate);
            allIntervals.push(...intervals);
            ruleNodes.push(annotatedNode);
        }

        const mergedIntervals = Interval.merge(allIntervals);
        return { hours: roundNumber(totalDurationInHours(mergedIntervals), 2), mergedIntervals, ruleNodes };
    }

    /**
     * Calculates and persists categorized hours for the given users and dates
     * according to the policy that applies to each user on each day.
     *
     * 1. Fetch policy associations grouped by `userId` and determine applicable policy
     * 2. Resolve the effective policy per `userId-dateString`.
     * 3. Check policy hour categories in for all `policyId-dateString` combinations and determine applicable categories
     * 4. Filter to only `userId-dateString` combinations that have both policy and hour categories.
     * 5. Fetch day summaries for user dates with policy hour categories.
     * 6. Calculate categorized hours per `userId-dateString`.
     * 7. Persist all categorized hours.
     */
    public handleCalculateCategorizedHoursQueue = async (
        message: QueueMessageType<QueueMessageTypes.TIME_TRACKING_CALCULATE_CATEGORIZED_HOURS>,
    ): Promise<void> => {
        const body = message.messageBody;
        if (!body || body.userIds.length === 0 || body.dateStrings.length === 0) return;
        const instance = await this.instancesService.findByPk(body.instanceId);
        await this.calculateAndStoreCategorizedHoursForDaySummaries(body.userIds, body.dateStrings, instance);
    };

    public async calculateAndStoreCategorizedHoursForDaySummaries(userIds: number[], dateStrings: string[], instance: Instance): Promise<void> {
        if (userIds.length === 0 || dateStrings.length === 0) return;

        const uniqueUserIds = [...new Set(userIds)];
        const uniqueDateStrings = [...new Set(dateStrings)];
        const sortedDates = uniqueDateStrings.sort();
        const minDate = sortedDates[0];
        const maxDate = sortedDates[sortedDates.length - 1];

        // STEP 1: Fetch active policy associations (toDate null) per user
        const policyAssociations = await this.timeTrackingPoliciesService.listActivePolicyAssociations(instance.getId(), uniqueUserIds);

        if (policyAssociations.length === 0) return;

        const associationsByUser = this.groupPolicyAssociationsByUser(policyAssociations);

        // STEP 2: Active policy per user (toDate null → one policy per user, no date)
        const activePolicyByUser = this.getActivePolicyByUser(uniqueUserIds, associationsByUser);

        if (activePolicyByUser.size === 0) return;

        // STEP 3: Policy hour categories per policyId-dateString (categories are still date-dependent)
        const policyHourCategoriesByDate = await this.fetchPolicyHourCategoriesByDate(
            uniqueUserIds,
            uniqueDateStrings,
            activePolicyByUser,
            instance.getId(),
        );

        // STEP 4: userId-dateString that have both active policy and hour categories
        const userDatesWithPolicyHourCategories = this.filterUserDatesWithPolicyHourCategories(
            uniqueUserIds,
            uniqueDateStrings,
            activePolicyByUser,
            policyHourCategoriesByDate,
        );

        if (userDatesWithPolicyHourCategories.size === 0) return;

        // STEP 5: Fetch day summaries for those user dates
        const daySummariesFetched = await this.fetchDaySummariesForUsersWithPolicyHourCategories(
            userDatesWithPolicyHourCategories,
            minDate,
            maxDate,
            instance,
        );

        if (daySummariesFetched.length === 0) return;

        const ongoingUserDateKeys = this.getOngoingWorkDaySummaries(daySummariesFetched);
        const daySummaries = daySummariesFetched.filter((s) => !ongoingUserDateKeys.has(`${s.userId}-${s.dateString}`));

        if (daySummaries.length === 0) return;

        // STEP 6: Calculate categorized hours per userId-dateString. Logs the executed AST per (user, date),
        // all lines correlated by a single `traceRunId` for this batch.
        const traceRunId = uuidv7();
        const categorizedHoursMap = this.calculateCategorizedHours(
            daySummaries,
            activePolicyByUser,
            policyHourCategoriesByDate,
            instance.getTimezone(),
            traceRunId,
        );

        // STEP 7: Persist categorized hours
        let pendingHours: { id: string; userId: number }[] = [];
        if (categorizedHoursMap.size > 0) {
            pendingHours = await this.timeTrackingHourCategorizationRepository.saveCategorizedHours(instance.getId(), categorizedHoursMap);
        }

        // STEP 8: Create approval step snapshots for newly created PENDING hours
        if (pendingHours.length > 0) {
            const createdFirstSteps = (
                await allPromises(
                    pendingHours.map(({ id, userId }) => async () => {
                        const policyId = activePolicyByUser.get(userId) ?? null;
                        return this.categorizedHourApprovalService.createApprovalStepsSnapshotIfNeeded(id, userId, instance.getId(), policyId);
                    }),
                )
            ).filter((s) => s != null);

            await allPromises(
                createdFirstSteps.map((step) => async () => {
                    await this.timeTrackingNotificationsPort
                        .notifyCategorizedHourStepPending(instance.getId(), step.categorizedHourId, step.id)
                        .catch((err) => this.logger.error('Failed to notify step pending', { err, categorizedHourId: step.categorizedHourId }));
                }),
            );
        }
    }

    private groupPolicyAssociationsByUser(policyAssociations: TimeTrackingPolicyAssociation[]): Map<number, TimeTrackingPolicyAssociation[]> {
        const associationsByUser = new Map<number, TimeTrackingPolicyAssociation[]>();

        for (const assoc of policyAssociations) {
            const userId = assoc.userId;
            let list = associationsByUser.get(userId) ?? [];

            list.push(assoc);
            associationsByUser.set(userId, list);
        }

        return associationsByUser;
    }

    /** Active policy per user (toDate null → one policy per user). */
    private getActivePolicyByUser(userIds: number[], associationsByUser: Map<number, TimeTrackingPolicyAssociation[]>): Map<number, number> {
        const activePolicyByUser = new Map<number, number>();
        for (const userId of userIds) {
            const userAssociations = associationsByUser.get(userId) ?? [];
            const policyId = userAssociations[0]?.policy?.id;
            if (policyId) activePolicyByUser.set(userId, policyId);
        }
        return activePolicyByUser;
    }

    private async fetchPolicyHourCategoriesByDate(
        userIds: number[],
        dateStrings: string[],
        activePolicyByUser: Map<number, number>,
        instanceId: number,
    ): Promise<Map<string, Array<{ category: HourCategory; requiresApproval: boolean }>>> {
        const policyHourCategoriesByDate = new Map<string, Array<{ category: HourCategory; requiresApproval: boolean }>>();
        const policyIdDateCombinations = new Set<string>();

        for (const userId of userIds) {
            const policyId = activePolicyByUser.get(userId);
            if (!policyId) continue;
            for (const dateString of dateStrings) {
                policyIdDateCombinations.add(`${policyId}-${dateString}`);
            }
        }

        const policyHourCategoriesPromises = [];
        for (const policyIdDateKey of policyIdDateCombinations) {
            const [policyId, ...dateParts] = policyIdDateKey.split('-');
            const dateString = dateParts.join('-');

            policyHourCategoriesPromises.push(async () => {
                const categories = await this.timeTrackingHourCategorizationRepository.getPolicyHourCategoriesForPolicyIds(
                    [Number(policyId)],
                    instanceId,
                    dateString,
                );

                if (categories.length > 0) {
                    policyHourCategoriesByDate.set(policyIdDateKey, categories);
                }
            });
        }

        await allPromises(policyHourCategoriesPromises);

        return policyHourCategoriesByDate;
    }

    private filterUserDatesWithPolicyHourCategories(
        userIds: number[],
        dateStrings: string[],
        activePolicyByUser: Map<number, number>,
        policyHourCategoriesByDate: Map<string, Array<{ category: HourCategory; requiresApproval: boolean }>>,
    ): Set<string> {
        const userDatesWithPolicyHourCategories = new Set<string>();
        for (const userId of userIds) {
            const policyId = activePolicyByUser.get(userId);
            if (!policyId) continue;
            for (const dateString of dateStrings) {
                if (policyHourCategoriesByDate.has(`${policyId}-${dateString}`)) {
                    userDatesWithPolicyHourCategories.add(`${userId}-${dateString}`);
                }
            }
        }
        return userDatesWithPolicyHourCategories;
    }

    private async fetchDaySummariesForUsersWithPolicyHourCategories(
        userDatesWithPolicyHourCategories: Set<string>,
        minDate: string,
        maxDate: string,
        instance: Instance,
    ): Promise<TimeTrackingDaySummary[]> {
        const userIdsWithPolicyHourCategories = new Set<number>();
        for (const userIdDateKey of userDatesWithPolicyHourCategories) {
            const userId = Number(userIdDateKey.split('-')[0]);
            userIdsWithPolicyHourCategories.add(userId);
        }

        const daySummariesMap = new Map<string, TimeTrackingDaySummary>();

        await allPromises(
            Array.from(userIdsWithPolicyHourCategories).map((userId) => async () => {
                const userDaySummaries = await this.timeTrackingService.getOrCreateDaySummariesByUser(userId, instance, minDate, maxDate);
                for (const daySummary of userDaySummaries) {
                    const key = `${daySummary.userId}-${daySummary.dateString}`;
                    if (userDatesWithPolicyHourCategories.has(key)) {
                        daySummariesMap.set(key, daySummary);
                    }
                }
            }),
        );

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

    /**
     * Receives a list of day summaries and returns a set of user-date keys
     * where the work day is still ongoing. This means the day summary has an unpaired START (no END yet).
     * This is used to filter out day summaries that have an ongoing work day because the categorized hours for that day will be calculated
     * once the final END entry is created.
     */
    private getOngoingWorkDaySummaries(daySummaries: TimeTrackingDaySummary[]): Set<string> {
        const ongoingWorkDaySummaries = new Set<string>();
        for (const summary of daySummaries) {
            const entries = summary.timeTrackingEntries ?? [];
            if (entries.length === 0) continue;

            const paired = PairedEntry.getPairedResults(entries);
            const hasUnpairedStart = paired.some((p) => p.start && !p.end);
            if (hasUnpairedStart) {
                ongoingWorkDaySummaries.add(`${summary.userId}-${summary.dateString}`);
            }
        }
        return ongoingWorkDaySummaries;
    }

    private calculateCategorizedHours(
        daySummaries: TimeTrackingDaySummary[],
        activePolicyByUser: Map<number, number>,
        policyHourCategoriesByDate: Map<string, Array<{ category: HourCategory; requiresApproval: boolean }>>,
        timezone: string,
        traceRunId: string,
    ): Map<string, TimeTrackingCategorizedHourForCreation[]> {
        const categorizedHoursMap = new Map<string, TimeTrackingCategorizedHourForCreation[]>();

        for (const daySummary of daySummaries) {
            const policyId = activePolicyByUser.get(daySummary.userId);
            if (!policyId) continue;

            const policyDateKey = `${policyId}-${daySummary.dateString}`;
            const policyHourCategories = policyHourCategoriesByDate.get(policyDateKey) ?? [];

            if (policyHourCategories.length === 0) continue;

            const dayNodes = this.buildDayCategoryNodes(daySummary, policyHourCategories, timezone);

            // Trace: one bounded log line per (user, date); `traceRunId` correlates all days of this batch.
            this.logger.info(
                {
                    traceRunId,
                    instanceId: daySummary.instanceId,
                    userId: daySummary.userId,
                    dateString: daySummary.dateString,
                    categories: dayNodes,
                },
                HOUR_CATEGORIZATION_TRACE_MESSAGE,
            );

            // Persist from the evaluated nodes (single source of truth with the trace above).
            // `dayNodes[i]` corresponds to `policyHourCategories[i]` (buildDayCategoryNodes maps in order),
            // so read `category`/`requiresApproval` positionally — byte-identical to the legacy inline loop,
            // and correct even if the same category id appears twice in the policy.
            const entryIds = (daySummary.timeTrackingEntries ?? []).map((e) => e.id);
            const categorizedHours: TimeTrackingCategorizedHourForCreation[] = [];

            policyHourCategories.forEach(({ category, requiresApproval }, i) => {
                const node = dayNodes[i];
                if (node.type !== 'CATEGORY' || node.totalHours <= 0) return;
                categorizedHours.push(
                    new TimeTrackingCategorizedHourForCreation({
                        dateString: daySummary.dateString,
                        entryIds: entryIds.length > 0 ? entryIds : null,
                        hourCategoryId: category.id,
                        hours: node.totalHours,
                        instanceId: daySummary.instanceId,
                        status: requiresApproval ? TimeTrackingCategorizedHourStatus.PENDING : TimeTrackingCategorizedHourStatus.AUTO_APPROVED,
                        userId: daySummary.userId,
                    }),
                );
            });

            if (categorizedHours.length > 0) {
                categorizedHoursMap.set(`${daySummary.userId}-${daySummary.dateString}`, categorizedHours);
            }
        }

        return categorizedHoursMap;
    }

    /**
     * Get approved categorized hours for day summaries
     * @param instanceId - The instance id
     * @param daySummaries - The day summaries
     * @returns The map of day summary id to approved categorized hours
     */
    public async getApprovedCategorizedHoursForDaySummaries(
        instanceId: number,
        daySummaries: TimeTrackingDaySummary[],
    ): Promise<Map<number, TimeTrackingCategorizedHourWithCategoryData[]>> {
        if (daySummaries.length === 0) {
            return new Map<number, TimeTrackingCategorizedHourWithCategoryData[]>();
        }

        const userIds = [...new Set(daySummaries.map((ds) => ds.userId))];
        const dateStrings = [...new Set(daySummaries.map((ds) => ds.dateString))];
        const approvedCategorizedHoursMap = await this.timeTrackingHourCategorizationRepository.getApprovedCategorizedHoursMap(
            instanceId,
            userIds,
            dateStrings,
        );

        const result = new Map<number, TimeTrackingCategorizedHourWithCategoryData[]>();
        for (const daySummary of daySummaries) {
            const key = `${daySummary.userId}-${daySummary.dateString}`;
            const categorizedHours = approvedCategorizedHoursMap.get(key) ?? [];
            if (categorizedHours.length > 0) {
                result.set(daySummary.id, categorizedHours);
            }
        }

        return result;
    }

    /**
     * Validates whether a categorized hour status transition is allowed.
     *
     * - A status of CANCELLED_BY_MODIFICATION cannot transition to any other status.
     * - A status cannot transition to PENDING if it is not already PENDING.
     * @param currentStatus - The current status
     * @param newStatus - The new status
     * @throws BadRequestError if the status transition is invalid
     */
    private validateStatusTransition(currentStatus: TimeTrackingCategorizedHourStatus, newStatus: TimeTrackingCategorizedHourStatus): void {
        if (currentStatus === TimeTrackingCategorizedHourStatus.CANCELLED_BY_MODIFICATION && currentStatus !== newStatus) {
            throw new BadRequestError('Categorized hour has been cancelled by modification', ErrorCodes.BAD_REQUEST);
        }
        if (currentStatus !== TimeTrackingCategorizedHourStatus.PENDING && newStatus === TimeTrackingCategorizedHourStatus.PENDING) {
            throw new BadRequestError('Categorized hour cant be changed to pending', ErrorCodes.BAD_REQUEST);
        }
    }
}
