import {
    ENV_OPTIONAL_NUMBER_KEYS_MAPPING,
    ErrorCodes,
    getAllIntersectionsOfArrays,
    IllegalStateError,
    NotFoundError,
    SEQUELIZE_INSTANCE_TOKEN,
} from '@humand-packages/common';
import { Op } from 'sequelize';
import { Sequelize } from 'sequelize-typescript';
import { Inject, Service } from 'typedi';
import { v7 as uuidv7 } from 'uuid';
import { BaseRepositoryAdapter } from '../../../../../dbAdapters/baseRepositoryAdapter';
import { FaceRecognitionConfigurationDAO } from '../../../../facialRecognition/infrastructure/dataAccessObjects/faceRecognitionConfigurations';
import TimeTrackingPoliciesFilters from '../../../business/interfaces/timeTrackingPoliciesFilters';
import TimeTrackingPoliciesOptions from '../../../business/interfaces/timeTrackingPoliciesOptions';
import TimeTrackingPoliciesSearchFilters from '../../../business/interfaces/timeTrackingPoliciesSearchFilters';
import TimeTrackingPoliciesUsersSearchFilters from '../../../business/interfaces/timeTrackingPoliciesUsersSearchsFilters';
import { TimeTrackingPolicy, TimeTrackingPolicyAssociation, TimeTrackingPolicyForCreation } from '../../../business/models/timeTrackingPolicy';
import { TimeTrackingPolicyHourCategoryAssociation } from '../../../business/models/TimeTrackingPolicyHourCategory';
import { TimeTrackingSetting } from '../../../business/models/timeTrackingSetting';
import { NightShiftAssignment } from '../../constants/setting';
import { TimeTrackingPolicyHourCategoryDAO } from '../../dataAccessObjects/hourCategory/policyHourCategory';
import { TimeTrackingPolicyDAO } from '../../dataAccessObjects/policy';
import { TimeTrackingPoliciesHistoryDAO } from '../../dataAccessObjects/policyHistory';
import { TimeTrackingPolicyUser, TimeTrackingPolicyUsersDAO } from '../../dataAccessObjects/policyUsers';
import { mapPolicyAssociationDAOToModel, mapPolicyDAOToModel, mapPolicyModelToDAO } from '../../mappers/timeTrackingPolicyMapper';
import { mapPolicyUserDAOToModel } from '../../mappers/timeTrackingPolicyUserMapper';
import TimeTrackingPoliciesRepositoryPort from '../../ports/TimeTrackingPoliciesRepositoryPort';

@Service()
export default class TimeTrackingPoliciesRepositoryAdapter extends BaseRepositoryAdapter implements TimeTrackingPoliciesRepositoryPort {
    constructor(
        @Inject(ENV_OPTIONAL_NUMBER_KEYS_MAPPING.BULK_CREATE_CHUNK_SIZE) bulkCreateChunkSize: number,
        @Inject(SEQUELIZE_INSTANCE_TOKEN) sequelize: Sequelize,
    ) {
        super(bulkCreateChunkSize, sequelize);
    }

    public listPolicies = async (
        policiesFilters: TimeTrackingPoliciesFilters,
        options: TimeTrackingPoliciesOptions,
    ): Promise<{ count: number; rows: TimeTrackingPolicy[] }> => {
        if (!!policiesFilters.fromDate !== !!policiesFilters.toDate) {
            throw new IllegalStateError('Date filters must be both present or both absent', ErrorCodes.ILLEGAL_STATE);
        }
        const where = {
            ...(policiesFilters.fromDate && policiesFilters.toDate
                ? {
                      fromDate: { [Op.lte]: policiesFilters.toDate },
                      toDate: { [Op.or]: [{ [Op.is]: null }, { [Op.gt]: policiesFilters.fromDate }] },
                  }
                : { toDate: null }),
            ...(policiesFilters.userIds?.length ? { userId: policiesFilters.userIds } : {}),
        };
        const { count, rows } = await TimeTrackingPolicyDAO.findAndCountAll({
            distinct: true,
            include: { model: TimeTrackingPolicyUsersDAO, required: !!policiesFilters.fromDate || !!policiesFilters.userIds?.length, where },
            limit: options.limit,
            offset: options.offset,
            order: [['name', 'ASC']],
            where: { instanceId: policiesFilters.instanceId },
        });
        return { count, rows: rows.map(mapPolicyDAOToModel) };
    };

    public listPolicyAssociations = async (
        policiesFilters: TimeTrackingPoliciesFilters,
        options: TimeTrackingPoliciesOptions,
    ): Promise<{ count: number; rows: TimeTrackingPolicyAssociation[] }> => {
        if (!!policiesFilters.fromDate !== !!policiesFilters.toDate) {
            throw new IllegalStateError('Date filters must be both present or both absent', ErrorCodes.ILLEGAL_STATE);
        }
        if (!policiesFilters.userIds?.length && !policiesFilters.fromDate) {
            throw new IllegalStateError('USer ids or date filter must be present', ErrorCodes.ILLEGAL_STATE);
        }
        const where = {
            ...(policiesFilters.fromDate && policiesFilters.toDate
                ? {
                      fromDate: { [Op.lte]: policiesFilters.toDate },
                      toDate: { [Op.or]: [{ [Op.is]: null }, { [Op.gt]: policiesFilters.fromDate }] },
                  }
                : { toDate: null }),
            ...(policiesFilters.userIds?.length ? { userId: policiesFilters.userIds } : {}),
        };
        const { count, rows } = await TimeTrackingPolicyUsersDAO.findAndCountAll({
            include: { model: TimeTrackingPolicyDAO, required: true, where: { instanceId: policiesFilters.instanceId } },
            limit: options.limit,
            offset: options.offset,
            where,
        });
        return { count, rows: rows.map(mapPolicyAssociationDAOToModel) };
    };

    public listActivePolicyAssociations = async (instanceId: number, userIds: number[]): Promise<TimeTrackingPolicyAssociation[]> => {
        if (userIds.length === 0) return [];
        const rows = await TimeTrackingPolicyUsersDAO.findAll({
            include: { model: TimeTrackingPolicyDAO, required: true, where: { instanceId } },
            where: { toDate: null, userId: userIds },
        });
        return rows.map(mapPolicyAssociationDAOToModel);
    };

    public listPolicyAssociationsEndedAt = async (
        instanceId: number,
        toDate: string,
        userIds: number[],
    ): Promise<TimeTrackingPolicyAssociation[]> => {
        const policyAssociations = await TimeTrackingPolicyUsersDAO.findAll({
            include: { model: TimeTrackingPolicyDAO, required: true, where: { instanceId } },
            where: { toDate, userId: userIds },
        });
        return policyAssociations.map(mapPolicyAssociationDAOToModel);
    };

    public getPolicyByIdOrFail = async (instanceId: number, policyId: number): Promise<TimeTrackingPolicy> => {
        const policyDAO = await TimeTrackingPolicyDAO.findByPk(policyId, {
            include: { model: TimeTrackingPolicyUsersDAO, required: false, where: { toDate: null } },
        });
        if (!policyDAO || policyDAO.instanceId !== instanceId) {
            throw new NotFoundError('Policy not found', ErrorCodes.NOT_FOUND);
        }
        return mapPolicyDAOToModel(policyDAO);
    };

    public createPolicy = async (instanceId: number, policy: TimeTrackingPolicyForCreation): Promise<TimeTrackingPolicy> => {
        const policyDAO = new TimeTrackingPolicyDAO({
            ...policy,
            instanceId,
        });
        await policyDAO.save();
        return mapPolicyDAOToModel(policyDAO);
    };

    /**
     * @param historyInvalidationFromDate YYYY-MM-DD lower bound (instance timezone) for invalidating
     * cached rows in TimeTrackingPoliciesHistory; pass `null` to skip invalidation when the
     * lateness tolerance is unchanged. Policy edits are non-retroactive: only days from this date
     * forward are recomputed; past day summaries keep the tolerance they were calculated with.
     */
    public updatePolicy = async (updatedPolicy: TimeTrackingPolicy, historyInvalidationFromDate: string | null): Promise<TimeTrackingPolicy> => {
        const policyDAO = mapPolicyModelToDAO(updatedPolicy);
        await policyDAO.save();
        // Retro compatibility, to be deleted in the future
        // Get Users asigned to policy
        const policyUserDAOs = await TimeTrackingPolicyUsersDAO.findAll({ where: { policyId: policyDAO.id, toDate: null } });
        if (policyUserDAOs && policyUserDAOs.length) {
            await this.syncFaceRecognitionConfigurationForUsers(
                policyDAO.instanceId,
                policyUserDAOs.map((policyUserDAO) => policyUserDAO.userId),
            );
            if (historyInvalidationFromDate) {
                await TimeTrackingPoliciesHistoryDAO.destroy({
                    where: {
                        dateString: {
                            [Op.gte]: historyInvalidationFromDate,
                        },
                        userId: {
                            [Op.in]: updatedPolicy.userIds,
                        },
                    },
                });
            }
        }

        return mapPolicyDAOToModel(policyDAO);
    };

    public associatePolicy = async (
        policyId: number,
        userIds: number[],
        fromDate: string,
        allUsers: boolean,
    ): Promise<{ usersNewlyAssigned: number[]; usersNowUnassigned: number[] }> => {
        const policyDAO = await TimeTrackingPolicyDAO.findByPk(policyId, {
            include: { model: TimeTrackingPolicyUsersDAO, required: false, where: { toDate: null } },
        });
        if (!policyDAO) {
            throw new NotFoundError('Policy not found', ErrorCodes.NOT_FOUND);
        }
        const alreadyAssignedUsers = new Set<number>();
        policyDAO.users?.forEach((user) => alreadyAssignedUsers.add(user.userId));
        const { leftSideArray: usersNowUnassigned, rightSideArray: usersNewlyAssigned } = getAllIntersectionsOfArrays(
            Array.from(alreadyAssignedUsers),
            userIds,
        );
        // Unassign from this policy
        await TimeTrackingPolicyUsersDAO.update({ toDate: fromDate }, { where: { policyId, toDate: null, userId: usersNowUnassigned } });
        // Assign to new policy
        await TimeTrackingPolicyUsersDAO.bulkCreate(usersNewlyAssigned.map((userId) => ({ fromDate, policyId, userId })));
        // Unassign from other previously assigned policies
        await TimeTrackingPolicyUsersDAO.update(
            { toDate: fromDate },
            {
                where: {
                    policyId: { [Op.ne]: policyId },
                    [Op.or]: [{ toDate: null }, { toDate: { [Op.gt]: fromDate } }],
                    userId: usersNewlyAssigned,
                },
            },
        );
        // Retro compatibility, to be deleted in the future
        await this.syncFaceRecognitionConfigurationForUsers(policyDAO.instanceId, usersNewlyAssigned);
        // Update the policy's all users field if needed
        if (allUsers !== policyDAO.allUsers) {
            await policyDAO.update({ allUsers });
        }

        if (allUsers) {
            await TimeTrackingPolicyDAO.update({ allUsers: false }, { where: { id: { [Op.ne]: policyId }, instanceId: policyDAO.instanceId } });
        }

        // Delete stored lateness associated to the users
        await TimeTrackingPoliciesHistoryDAO.destroy({
            where: {
                dateString: {
                    [Op.gte]: fromDate,
                },
                userId: {
                    [Op.in]: userIds,
                },
            },
        });

        return {
            usersNewlyAssigned,
            usersNowUnassigned,
        };
    };

    public syncFaceRecognitionConfigurationForUsers = async (instanceId: number, userIds: number[]): Promise<void> => {
        const userPolicies = await this.listPoliciesUsers(instanceId, { policyUserFilters: { userIds } });
        const configurations = userPolicies.map((userPolicy) => ({
            enabled: !!userPolicy.policy?.faceRecognitionEnabled,
            instanceId,
            isBlocking: !!userPolicy.policy?.faceRecognitionBlocking,
            userId: userPolicy.userId,
        }));

        await Promise.all(
            configurations.map(async (config) => {
                await FaceRecognitionConfigurationDAO.update(
                    { enabled: config.enabled, isBlocking: config.isBlocking },
                    { where: { instanceId: config.instanceId, userId: config.userId } },
                );
            }),
        );

        await FaceRecognitionConfigurationDAO.bulkCreate(configurations, { ignoreDuplicates: true });
    };

    public eliminatePolicy = async (policyId: number, instanceId: number): Promise<void> => {
        const rowsDeleted = await TimeTrackingPolicyDAO.destroy({
            where: {
                id: policyId,
                instanceId,
            },
        });
        if (rowsDeleted) {
            await TimeTrackingPolicyUsersDAO.destroy({
                where: {
                    policyId,
                },
            });
        }
    };

    public listPoliciesUsers = async (
        instanceId: number,
        filters: {
            policyFilters?: Partial<TimeTrackingPoliciesSearchFilters>;
            policyUserFilters?: Partial<TimeTrackingPoliciesUsersSearchFilters>;
        } = {},
    ): Promise<TimeTrackingPolicyUser[]> => {
        if (!!filters.policyUserFilters?.fromDate !== !!filters.policyUserFilters?.toDate)
            throw new IllegalStateError('Date filters must be both present or both absent', ErrorCodes.ILLEGAL_STATE);

        const wherePolicyUser = {
            ...(filters.policyUserFilters?.fromDate && filters.policyUserFilters?.toDate
                ? {
                      fromDate: { [Op.lte]: filters.policyUserFilters.toDate },
                      toDate: { [Op.or]: [{ [Op.is]: null }, { [Op.gt]: filters.policyUserFilters.fromDate }] },
                  }
                : { toDate: null }),
            ...(filters.policyUserFilters?.userIds?.length ? { userId: filters.policyUserFilters?.userIds } : {}),
        };
        const wherePolicy = {
            instanceId,
            ...filters.policyFilters,
        };
        const policyUsers = await TimeTrackingPolicyUsersDAO.findAll({
            include: [
                {
                    model: TimeTrackingPolicyDAO,
                    required: true,
                    where: wherePolicy,
                },
            ],
            where: wherePolicyUser,
        });
        return policyUsers ? policyUsers.map(mapPolicyUserDAOToModel) : [];
    };

    public getPoliciesOfInstance = async (instanceId: number): Promise<TimeTrackingPolicy[]> => {
        const policies = await TimeTrackingPolicyDAO.findAll({
            include: { model: TimeTrackingPolicyUsersDAO, required: false, where: { toDate: null } },
            where: { instanceId },
        });
        return policies.map(mapPolicyDAOToModel);
    };

    public updateSettingsInPolicies = async (instanceId: number, settings: Partial<TimeTrackingSetting>): Promise<number[]> => {
        const policies = await TimeTrackingPolicyDAO.findAll({ where: { instanceId } });
        const policiesIds = policies.map((policy) => policy.id);
        await TimeTrackingPolicyDAO.update(
            { nightShift: settings.nightShiftAssignment === NightShiftAssignment.SCHEDULE_INIT },
            {
                where: {
                    id: policiesIds,
                    instanceId,
                },
            },
        );
        return policiesIds;
    };

    /**
     * Associate hour categories to a given policy.
     * Hour categories currently associated with the policy but not present in the input list will be removed from the policy.
     * Hour categories present in the input list will be created if missing or updated (only the `requiresApproval` field) if already associated.
     * Passing an empty list will delete all policy hour categories for the policy.
     * @param policyId - The policy id to associate the hour categories to
     * @param policyHourCategories - The hour categories to associate to the policy
     * @param instanceId - The instance id
     * @returns void
     */
    public associatePolicyHourCategories = async (
        policyId: number,
        policyHourCategories: Array<TimeTrackingPolicyHourCategoryAssociation>,
        instanceId: number,
    ): Promise<number[]> => {
        const existingPolicyHourCategories = await TimeTrackingPolicyHourCategoryDAO.findAll({
            where: { instanceId, policyId },
        });

        const updatingHourCategoryIds = policyHourCategories.map((policyHourCategory) => policyHourCategory.hourCategoryId);
        const categoriesToDelete = existingPolicyHourCategories.filter((existing) => !updatingHourCategoryIds.includes(existing.hourCategoryId));
        const removedHourCategoryIds = categoriesToDelete.map((c) => c.hourCategoryId);

        if (categoriesToDelete.length > 0) {
            await TimeTrackingPolicyHourCategoryDAO.destroy({
                where: {
                    hourCategoryId: {
                        [Op.notIn]: updatingHourCategoryIds,
                    },
                    instanceId,
                    policyId,
                },
            });
        }

        if (policyHourCategories.length === 0) {
            return removedHourCategoryIds;
        }

        await TimeTrackingPolicyHourCategoryDAO.bulkCreate(
            policyHourCategories.map((policyHourCategory) => ({
                hourCategoryId: policyHourCategory.hourCategoryId,
                id: uuidv7(),
                instanceId,
                policyId,
                requiresApproval: policyHourCategory.requiresApproval,
            })),
            {
                conflictAttributes: ['instanceId', 'policyId', 'hourCategoryId'],
                conflictWhere: {
                    deletedAt: null,
                },
                updateOnDuplicate: ['requiresApproval'],
            },
        );

        return removedHourCategoryIds;
    };

    public countPoliciesByInstance = async (instanceId: number): Promise<number> => {
        return await TimeTrackingPolicyDAO.count({
            where: {
                instanceId,
            },
        });
    };

    public listPoliciesByIds = async (policyIds: number[]): Promise<TimeTrackingPolicy[]> => {
        const rows = await TimeTrackingPolicyDAO.findAll({
            include: { model: TimeTrackingPolicyUsersDAO, required: false, where: { toDate: null } },
            where: { id: { [Op.in]: policyIds } },
        });
        return rows.map(mapPolicyDAOToModel);
    };
}
