import {
    ScimError,
    InvalidScimPatch,
    InvalidScimPatchOp,
    NoPathInScimPatchOp,
    InvalidScimPatchRequest,
    NoTarget,
    DeepArrayRemovalNotSupported,
    UnsupportedBlueprintEntities
} from './errors/scimErrors';
import {
    ScimPatchSchema,
    ScimId,
    ScimSchema,
    ScimPatchOperation,
    ScimPatchRemoveOperation,
    ScimPatchAddReplaceOperation,
    ScimPatch,
    ScimResource,
    ScimMeta
} from './types/types';
import {parse, filter} from 'scim2-parse-filter';
import deepEqual = require('fast-deep-equal');

/*
 * Export types
 */
export {
    ScimPatchSchema,
    ScimId,
    ScimSchema,
    ScimPatchOperation,
    ScimPatchRemoveOperation,
    ScimPatchAddReplaceOperation,
    ScimPatch,
    ScimResource,
    ScimMeta,
    ScimError,
    InvalidScimPatch,
    InvalidScimPatchOp,
    NoPathInScimPatchOp,
    InvalidScimPatchRequest,
    NoTarget,
    DeepArrayRemovalNotSupported,
    UnsupportedBlueprintEntities
};
/*
 * This file implement the SCIM PATCH specification.
 * RFC : https://tools.ietf.org/html/rfc7644#section-3.5.2
 * It allow to apply some patch on an existing SCIM resource.
 */
// Regex to check if this is search into array request.
const IS_ARRAY_SEARCH = /(\[|\])/;
// Regex to extract key and search request (ex: emails[primary eq true).
const ARRAY_SEARCH: RegExp = /^(.+)\[(.+)\]$/;
// Split path on all periods except e.g. "2.0"
const SPLIT_PERIOD = /(?!\d)\.(?!\d)/g;
// Valid patch operation, value needs to be in lowercase here.
const AUTHORIZED_OPERATION = ['remove', 'add', 'replace'];

export const PATCH_OPERATION_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:PatchOp';
/*
 * PatchBodyValidation validate if the request body of the SCIM Patch is valid.
 * If the body is not valid the function throw an error.
 * @param body data from the patch request.
 * @throws {InvalidScimPatchRequest} if one operation is not valid.
 * @throws {NoPathInScimPatchOp} if one operation is a remove with no path.
 */
export function patchBodyValidation(body: ScimPatch): void {
    if (!body.schemas || !body.schemas.includes(PATCH_OPERATION_SCHEMA))
        throw new InvalidScimPatchRequest('Missing schemas.');

    if (!body.Operations || body.Operations.length <= 0)
        throw new InvalidScimPatchRequest('Missing operations.');

    body.Operations.forEach(validatePatchOperation);
}

/*
 * This method apply patch operations on a SCIM Resource.
 * @param scimResource The initial resource
 * @param patchOperations An array of SCIM patch operations we want to apply on the scimResource object.
 * @return the scimResource patched.
 * @throws {InvalidScimPatchOp} if the patch could not happen.
 */
export function scimPatch<T extends ScimResource>(scimResource: T, patchOperations: Array<ScimPatchOperation>): T {
    return patchOperations.reduce((patchedResource, patch) => {
        switch (patch.op) {
            case 'remove':
            case 'Remove':
                return applyRemoveOperation(patchedResource, patch);
            case 'add':
            case 'Add':
            case 'replace':
            case 'Replace':
                return applyAddOrReplaceOperation(patchedResource, patch);
            default:
                throw new InvalidScimPatchRequest(`Operator is invalid for SCIM patch request. ${patch}`);
        }
    }, scimResource);
}

/*
 * validateOperation is validating that the SCIM Patch Operation follow the RFC.
 * If not, the function throw an Error.
 * @param operation The SCIM operation we want to check.
 * @throws {InvalidScimPatchRequest} if the operation is not valid.
 * @throws {NoPathInScimPatchOp} if the operation is a remove with no path.
 */
function validatePatchOperation(operation: ScimPatchOperation): void {
    if (!operation.op || Array.isArray(operation.op) || !isValidOperation(operation.op))
        throw new InvalidScimPatchRequest(`Invalid op "${operation.op}" in the request.`);

    if (operation.op === 'remove' && !operation.path)
        throw new NoPathInScimPatchOp();

    if (operation.op === 'add' && !('value' in operation))
        throw new InvalidScimPatchRequest(`The operation ${operation.op} MUST contain a "value" member whose content specifies the value to be added`);

    if (operation.path && typeof operation.path !== 'string')
        throw new InvalidScimPatchRequest('Path is supposed to be a string');
}

function applyRemoveOperation<T extends ScimResource>(scimResource: T, patch: ScimPatchRemoveOperation): T {
    // We manipulate the object directly without knowing his property, that's why we use any.
    let resource: Record<string, any> = scimResource;
    validatePatchOperation(patch);

    // Path is supposed to be set, there are a validation in the validateOperation function.
    const paths = patch.path.split(SPLIT_PERIOD);
    const value = patch.value;


    resource = navigate(resource, paths);

    // Dealing with the last element of the path.
    const lastSubPath = paths[paths.length - 1];

    if (!IS_ARRAY_SEARCH.test(lastSubPath)) {
        // This is a mono valued property
        if (!value) {
            // No value in the remove operation, we delete it.
            delete resource[lastSubPath];
            return scimResource;
        }

        // Value in the remove operation, we remove the children by value.
        resource[lastSubPath] = filterWithArray(resource[lastSubPath], value);
        return scimResource;
    }

    // The last element is an Array request.
    const {attrName, valuePath, array} = extractArray(lastSubPath, resource);

    // We keep only items who don't match the query if supplied.
    resource[attrName] = array.filter((e: any) => !filterWithQuery<any>(array, valuePath).includes(e));

    // If the complex multi-valued attribute has no remaining records, the attribute SHALL be considered unassigned.
    if (resource[attrName].length === 0) delete resource[attrName];
    
    return scimResource;
}


function applyAddOrReplaceOperation<T extends ScimResource>(scimResource: T, patch: ScimPatchAddReplaceOperation): T {
    // We manipulate the object directly without knowing his property, that's why we use any.
    let resource: Record<string, any> = scimResource;
    validatePatchOperation(patch);

    if (!patch.path)
        return addOrReplaceAttribute(scimResource, patch);

    // We navigate till the second to last of the path.
    const paths = patch.path.split(SPLIT_PERIOD);
    resource = navigate(resource, paths);
    const lastSubPath = paths[paths.length - 1];

    if (!IS_ARRAY_SEARCH.test(lastSubPath)) {
        if (resource === undefined) {
            throw new NoTarget(patch.value)
        }
        resource[lastSubPath] = addOrReplaceAttribute(resource[lastSubPath], patch);
        return scimResource;
    }

    // The last element is an Array request.
    const {valuePath, array} = extractArray(lastSubPath, resource);

    // Get the list of items who are successful for the search query.
    const matchFilter = filterWithQuery<any>(array, valuePath);

    // If the target location specifies a complex attribute, a set of sub-attributes SHALL be specified in the "value"
    // parameter, which replaces any existing values or adds where an attribute did not previously exist.
    const isReplace = patch.op.toLowerCase() === 'replace';
    if (isReplace && matchFilter.length === 0) {
        array.push(patch.value);
        return scimResource;
    }

    // We are sure to find an index because matchFilter comes from array.
    const index = array.findIndex(item => matchFilter.includes(item));
    array[index] = addOrReplaceAttribute(array[index], patch);

    return scimResource;
}

/**
 * extractArray extract the valuePath (ex: email[primary eq true]) of a subPath
 * @param subPath The key we want to extract.
 * @param schema The object which is supposed to contains the array.
 * @return an array with the array name and the filter path.
 */
function extractArray(subPath: string, schema: any): ScimSearchQuery {
    // We extract the key of the table and what is inside [].
    const matchRequest = subPath.match(ARRAY_SEARCH);
    if (!matchRequest)
        throw new InvalidScimPatchOp(`This part of the path ${subPath} is invalid for SCIM patch request.`);

    const [, attrName, valuePath] = matchRequest;
    const element = schema[attrName];

    if (!Array.isArray(element))
        throw new InvalidScimPatchOp('Impossible to search on a mono valued attribute.');

    return new ScimSearchQuery(attrName, valuePath, element);
}



/**
 * navigate allow to get the sub object who want to edit with the patch operation.
 * @param inputSchema the initial ScimResource
 * @param paths an Array who contains the path of the sub object
 * @return the parent object of the element we want to edit
 */
function navigate(inputSchema: any, paths: string[]): any {
    let schema = inputSchema;
    for (let i = 0; i < paths.length - 1; i++) {
        const subPath = paths[i];

        // We check if the element is an array with query (ex: emails[primary eq true).
        if (IS_ARRAY_SEARCH.test(subPath)) {
            const {valuePath, array} = extractArray(subPath, schema);

            try {
                // Get the item who is successful for the search query.
                const matchFilter = filterWithQuery<any>(array, valuePath);
                // We are sure to find an index because matchFilter comes from array.
                const index = array.findIndex(item => matchFilter.includes(item));
                schema = array[index];
            } catch (error) {
                throw new InvalidScimPatchOp(error);
            }
        } else {
            // The element is not an array.
            if (!schema[subPath])
                schema[subPath] = {};
            schema = schema[subPath];
        }
    }
    return schema;
}

/**
 * Add or Replace a property in the ScimResource
 * @param property The property we want to replace
 * @param patch The patch operation
 * @return the patched property
 */
function addOrReplaceAttribute(property: any, patch: ScimPatchAddReplaceOperation): any {
    if (Array.isArray(property)) {
        if (Array.isArray(patch.value)) {
            // if we're adding an array, we need to remove duplicated values from existing array
            if (patch.op.toLowerCase() === "add") {
                const valuesToAdd = patch.value.filter(item => !property.includes(item))
                return property.concat(valuesToAdd);
            }
            // else this is a replace operation
            return patch.value;
        }

        const a = property;
        if (!a.includes(patch.value))
            a.push(patch.value);
        return a;
    }

    if (typeof property === 'object') {
        if (typeof patch.value !== 'object') {
            if (patch.op === 'add')
                throw new InvalidScimPatchOp('Invalid patch query.');

            return patch.value;
        }

        return {
            ...property,
            ...patch.value
        };
    }

    // If the target location specifies a single-valued attribute, the existing value is replaced.
    return patch.value;
}

/**
 * Return the items in the array who match the filter.
 * @param arr the collection where we are searching.
 * @param querySearch the search request.
 * @return an array who contains the search results.
 */
function filterWithQuery<T>(arr: Array<T>, querySearch: string): Array<T> {
    try {
        return arr.filter(filter(parse(querySearch)));
    } catch (error) {
        throw new InvalidScimPatchOp(error);
    }
}

/**
 * Return the array without items supplied in .
 * @param arr the collection where we are searching.
 * @param itemsToRemove array with items to remove from original.
 * @return an array which contains the search results.
 */
function filterWithArray<T>(arr: T[], itemsToRemove: T[] | Record<string, any>): T[] {
    if (!Array.isArray(arr)) throw new UnsupportedBlueprintEntities();
    if (isObject(itemsToRemove)) {
        let shouldResume = true;
        while (shouldResume) {
            const index = arr.findIndex((mainItem) => deepEqual(itemsToRemove, mainItem));
            dropItemFromArray(arr, index);
            if (index === -1) {
                shouldResume = false;
            }
        }
        return arr;
    }

    (itemsToRemove as T[]).forEach((itemToRemove) => {
        if (Array.isArray(itemToRemove)) throw new DeepArrayRemovalNotSupported();

        let shouldResume = true;
        while (shouldResume) {
            const index = arr.findIndex((mainItem) => deepEqual(itemToRemove, mainItem));
            dropItemFromArray(arr, index);
            if (index === -1) {
                shouldResume = false;
            }
        }
    })
    return arr;
}

function dropItemFromArray<T>(arr: T[], index: number) {
    if (index === -1) return;
    
    const mainArrMaxIndex = arr.length - 1;
    [arr[index], arr[mainArrMaxIndex]] = [arr[mainArrMaxIndex], arr[index]]
    arr.pop()
    return;
}


function isObject(object: Record<string, any>): boolean {
  return object != null && typeof object === 'object' && !Array.isArray(object);
}

function isValidOperation(operation: string): boolean {
    return AUTHORIZED_OPERATION.includes(operation.toLowerCase());
}

class ScimSearchQuery {
    constructor(
      readonly attrName: string,
      readonly valuePath: string,
      readonly array: Array<any>
    ) {
    }
}
