import { AbilityTuple, PureAbility, Subject, subject } from '@casl/ability'
import { permittedFieldsOf } from '@casl/ability/extra'
import { prismaQuery, PrismaQuery } from '@casl/prisma'
import { Prisma } from '@prisma/client'
import type { DMMF } from '@prisma/generator-helper'

type DefaultCaslAction = "create" | "read" | "update" | "delete"

export type PrismaExtensionCaslOptions = {
    /**
     * will add a field on each returned prisma result that stores allowed actions on result (not nested) 
     * so instead of { id: 0 } it would return { id: 0, [permissionField]: ['create', 'read', 'update', 'delete'] } 
     * 
     * to return other actions, please use addPermissionActions
     */
    permissionField?: string
    /** 
     * adds additional permission actions to ['create', 'read', 'update', 'delete']
     * that should be returned if permissionField is used.
     */
    addPermissionActions?: string[]
    /** uses transaction to allow using client queries before actual query, if fails, whole query will be rolled back */
    beforeQuery?: (tx: Prisma.TransactionClient) => Promise<void>,
    /** uses transaction to allow using client queries after actual query, if fails, whole query will be rolled back */
    afterQuery?: (tx: Prisma.TransactionClient) => Promise<void>,
    /** max wait for batch transaction - default 30000 */
    txMaxWait?: number
    /** timeout for batch transaction - default 30000 */
    txTimeout?: number
}

export type PrismaCaslOperation =
    'create' |
    'createMany' |
    'createManyAndReturn' |
    'upsert' |
    'findFirst' |
    'findFirstOrThrow' |
    'findMany' |
    'findUnique' |
    'findUniqueOrThrow' |
    'aggregate' |
    'count' |
    'groupBy' |
    'update' |
    'updateMany' |
    'updateManyAndReturn' |
    'delete' |
    'deleteMany'

export const caslOperationDict: Record<
    PrismaCaslOperation,
    {
        action: DefaultCaslAction
        dataQuery: boolean
        whereQuery: boolean
        includeSelectQuery: boolean
    }
> = {
    create: { action: 'create', dataQuery: true, whereQuery: false, includeSelectQuery: true },
    createMany: { action: 'create', dataQuery: true, whereQuery: false, includeSelectQuery: true },
    createManyAndReturn: { action: 'create', dataQuery: true, whereQuery: false, includeSelectQuery: true },
    upsert: { action: 'create', dataQuery: true, whereQuery: true, includeSelectQuery: true },
    findFirst: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: true },
    findFirstOrThrow: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: true },
    findMany: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: true },
    findUnique: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: true },
    findUniqueOrThrow: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: true },
    aggregate: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: false },
    count: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: false },
    groupBy: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: false },
    update: { action: 'update', dataQuery: true, whereQuery: true, includeSelectQuery: true },
    updateMany: { action: 'update', dataQuery: true, whereQuery: true, includeSelectQuery: false },
    updateManyAndReturn: { action: 'update', dataQuery: true, whereQuery: true, includeSelectQuery: false },
    delete: { action: 'delete', dataQuery: false, whereQuery: true, includeSelectQuery: true },
    deleteMany: { action: 'delete', dataQuery: false, whereQuery: true, includeSelectQuery: false },
} as const

export const caslNestedOperationDict: Record<string, 'create' | 'update' | 'read' | 'delete'> = {
    upsert: 'create',
    connect: 'update',
    connectOrCreate: 'create',
    create: 'create',
    createMany: 'create',
    update: 'update',
    updateMany: 'update',
    delete: 'delete',
    deleteMany: 'delete',
    disconnect: 'update',
    set: 'update'
}

export const relationFieldsByModel = Object.fromEntries(Prisma.dmmf.datamodel.models.map((model: DMMF.Model) => {
    const relationFields = Object.fromEntries(model.fields
        .filter((field) => field && field.kind === 'object' && field.relationName)
        .map((field) => ([field.name, field])))
    return [model.name, relationFields]
}))

export const propertyFieldsByModel = Object.fromEntries(Prisma.dmmf.datamodel.models.map((model: DMMF.Model) => {
    const propertyFields = Object.fromEntries(model.fields
        .filter((field) => !(field && field.kind === 'object' && field.relationName))
        .map((field) => {
            const relation = Object.values(relationFieldsByModel[model.name]).find((value: any) => value.relationFromFields.includes(field.name))
            return [field.name, relation?.name]
        }))
    return [model.name, propertyFields]
}))

export function pick<T extends Record<string, K>, K extends keyof T>(obj: T | undefined, keys: K[]) {
    return keys.reduce((acc, val) => {
        if (obj && val in obj) {
            (acc[val] = obj[val]);
        }
        return acc;
    }, {} as Pick<T, K>);
}

/**
 * goes through all permitted fields of a model
 * 
 * - if there area only rules with fields, permittedFields are at least empty
 * 
 * - if a rule has fields And conditions, it's fields are only permitted
 * if the query overlaps with the conditions
 * 
 * - if permittedFields are empty, but there is a rule without fields,
 * the result is undefined. allowing us to query for all fields
 * 
 * @param abilities 
 * @param action 
 * @param model 
 * @param obj 
 * @returns 
 */
export function getPermittedFields(
    abilities: PureAbility<AbilityTuple, PrismaQuery>,
    action: string,
    model: string,
    obj?: any
) {
    const modelFields = Object.keys(propertyFieldsByModel[model])
    const permittedFields = permittedFieldsOf(abilities, action, obj ? getSubject(model, obj) : model, {
        fieldsFrom: rule => {
            return rule.fields || modelFields;
        }
    })
    return permittedFields
}

/**
 * helper function to get subject for a model
 * @param model prisma model
 * @param obj 
 * @returns 
 */
export function getSubject(model: string, obj: any) {
    const modelFields = Object.keys(propertyFieldsByModel[model])
    const subjectFields = [...modelFields, ...Object.keys(relationFieldsByModel[model])]
    return subject(model, pick(obj, subjectFields))
}

export function getFluentField(data: any) {
    const dataPath = data?.__internalParams?.dataPath as string[]
    if (dataPath?.length > 0) {
        return dataPath[dataPath.length - 1]
    } else {
        return undefined
    }
}
/**
 * if fluent api is used `client.user.findUnique().post()`
 * we need to get its model
 * https://github.com/prisma/prisma/blob/cebc9c0ceb91ff9c80f0b149f3a7ff112fbb46fd/packages/client/src/runtime/core/model/applyFluent.ts#L20
 * @param startModel query model
 * @param data query args with internalParams - includes a dataPath for fluent api
 * @returns fluent api model
 */
export function getFluentModel(startModel: string, data: any) {
    const startRelation = {
        fluentModel: startModel,
        fluentRelationField: undefined as DMMF.Field | undefined,
        fluentRelationModel: undefined as string | undefined
    }
    const dataPath = data?.__internalParams?.dataPath as string[]
    if (dataPath?.length > 0) {
        return dataPath.filter((x) => x !== 'select').reduce((acc, x) => {
            acc.fluentRelationField = relationFieldsByModel[acc.fluentModel][x]
            acc.fluentModel = acc.fluentRelationField.type
            acc.fluentRelationModel = x
            return acc
        }, startRelation)
    } else {
        return startRelation
    }
}

export function isSubset(obj1: any, obj2: any): boolean {
    if (obj1 === obj2) return true;

    if (typeof obj1 === "object" && typeof obj2 === "object") {
        if (Array.isArray(obj1) && Array.isArray(obj2)) {
            for (const item1 of obj1) {
                let found = false;
                for (const item2 of obj2) {
                    if (isSubset(item1, item2)) {
                        found = true;
                        break;
                    }
                }
                if (!found) return false;
            }
            return true;
        } else {

            for (const key in obj1) {
                if (!(key in obj2) || !isSubset(obj1[key], obj2[key])) {
                    return false;
                }
            }
            return true;
        }
    }
    return false;
}
