import ApiRequestService from "./apiRequestService"
import {Repository} from "pinia-orm"
import UrlUtils from "~/services/utils/urlUtils"
import HydraDenormalizer from "./normalizer/hydraDenormalizer"
import ApiModel from "~/models/ApiModel"
import ApiResource from "~/models/ApiResource"
import MyProfile from "~/models/Access/MyProfile"
import {v4 as uuid4} from 'uuid'
import {AssociativeArray, Collection} from "~/types/data.d"
import models from "~/models/models";
import {useAccessProfileStore} from "~/stores/accessProfile"
import _ from "lodash"

/**
 * Entity manager: make operations on the models defined with the Pinia-Orm library
 *
 * @see https://pinia-orm.codedredd.de/
 */
class EntityManager {
    protected CLONE_PREFIX = '_clone_'

    /**
     * In instance of ApiRequestService
     * @protected
     */
    protected apiRequestService: ApiRequestService

    /**
     * A method to retrieve the repository of a given ApiResource
     * @protected
     */
    protected _getRepo: (model: typeof ApiResource) => Repository<ApiResource>

    public constructor(
        apiRequestService: ApiRequestService,
        getRepo: (model: typeof ApiResource) => Repository<ApiResource>
    ) {
        this.apiRequestService = apiRequestService
        this._getRepo = getRepo
    }

    /**
     * Return the repository for the model
     *
     * @param model
     */
    public getRepository(model: typeof ApiResource): Repository<ApiResource> {
        return this._getRepo(model)
    }

    /**
     * Cast an object as an ApiResource
     * This in used internally to ensure the object is recognized as an ApiResource
     *
     * @param model
     * @param instance
     * @protected
     */
    // noinspection JSMethodCanBeStatic
    protected cast(model: typeof ApiResource, instance: ApiResource): ApiResource {
        return new model(instance)
    }

    /**
     * Return the model class with the given entity name
     *
     * @param entityName
     */
    public getModelFor(entityName: string): typeof ApiResource {
        return models[entityName]
    }

    /**
     * Return the model class from an Opentalent Api IRI
     *
     * @param iri An IRI of the form .../api/<entity>/...
     */
    public getModelFromIri(iri: string): typeof ApiResource {
        const matches = iri.match(/^\/api\/(\w+)\/.*/)
        if (!matches || !matches[1]) {
            throw new Error('cannot parse the IRI')
        }
        return this.getModelFor(matches[1])
    }

    /**
     * Create a new instance of the given model
     *
     * @param model
     * @param properties
     */
    public newInstance(model: typeof ApiResource, properties: object = {}): ApiResource {
        const repository = this.getRepository(model)

        let instance = repository.make(properties)

        // Keep track of the model
        // TODO : attendre de voir si utile ou non
        // instance.setModel(model)

        // @ts-ignore
        if (!properties.hasOwnProperty('id') || !properties.id) {
            // Object has no id yet, we give him a temporary one
            instance.id = 'tmp' + uuid4()
        }

        return this.save(model, instance, true)
    }

    /**
     * Save the model instance into the store
     *
     * @param model
     * @param instance
     * @param permanent Is the change already persisted in the datasource? If this is the case, the initial state of this
     *                  record is also updated.
     */
    public save(model: typeof ApiResource, instance: ApiResource, permanent: boolean = false): ApiResource {
        if (permanent) {
            this.saveInitialState(model, instance)
        }
        return this.getRepository(model).save(instance)
    }

    /**
     * Find the model instance in the store
     * TODO: comment réagit la fonction si l'id n'existe pas?
     *
     * @param model
     * @param id
     */
    // @ts-ignore
    public find<T extends ApiResource>(model: typeof T, id: number): T {
        const repository = this.getRepository(model)
        return repository.find(id) as T
    }

    /**
     * Fetch an ApiModel / ApiResource by its id, save it to the store and returns it
     *
     * @param model  Model of the object to fetch
     * @param id   Id of the object to fetch
     * @param forceRefresh  Force a new get request to the api ;
     *                      current object in store will be overwritten if it exists
     */
    public async fetch(model: typeof ApiResource, id: number, forceRefresh: boolean = false): Promise<ApiResource> {

        // If the model instance is already in the store and forceRefresh is false, return the object in store
        if (!forceRefresh) {
            const item = this.find(model, id)
            if (item && typeof item !== 'undefined') {
                return item
            }
        }

        // Else, get the object from the API
        const url = UrlUtils.join('api', model.entity, String(id))
        const response = await this.apiRequestService.get(url)

        // deserialize the response
        const attributes = HydraDenormalizer.denormalize(response).data as object
        return this.newInstance(model, attributes)
    }

    /**
     * Fetch a collection of model instances
     * The content of `query` is converted into a query-string in the request URL
     *
     * @param model
     * @param query
     * @param parent
     */
    public async fetchCollection(model: typeof ApiResource, parent: ApiResource | null, query: AssociativeArray = []): Promise<Collection> {
        let url

        if (parent !== null) {
            url = UrlUtils.join('api', parent.entity, '' + parent.id, model.entity)
        } else {
            url = UrlUtils.join('api', model.entity)
        }

        const response = await this.apiRequestService.get(url, query)

        // deserialize the response
        const apiCollection = HydraDenormalizer.denormalize(response)

        const items = apiCollection.data.map((attributes: object) => {
            return this.newInstance(model, attributes)
        })

        return {
            items,
            totalItems: apiCollection.metadata.totalItems,
            pagination: {
                first: apiCollection.metadata.firstPage || 1,
                last: apiCollection.metadata.lastPage || 1,
                next: apiCollection.metadata.nextPage || undefined,
                previous: apiCollection.metadata.previousPage || undefined,
            }
        }
    }

    /**
     * Persist the model instance as it is in the store into the data source via the API
     *
     * @param model
     * @param instance
     */
    public async persist(model: typeof ApiModel, instance: ApiModel) {
        // Recast in case class definition has been "lost"
        // TODO: attendre de voir si cette ligne est nécessaire
        // instance = this.cast(model, instance)

        let url = UrlUtils.join('api', model.entity)
        let response

        const data: any = instance.$toJson()

        if (!instance.isNew()) {
            url = UrlUtils.join(url, String(instance.id))
            response = await this.apiRequestService.put(url, data)
        } else {
            delete data.id
            response = await this.apiRequestService.post(url, data)
        }

        const hydraResponse = await HydraDenormalizer.denormalize(response)

        const newInstance = this.newInstance(model, hydraResponse.data)

        // Si l'instance était nouvelle avant d'être persistée, elle vient désormais de recevoir un id définitif. On
        // peut donc supprimer l'instance temporaire.
        if (instance.isNew()) {
            this.removeTempAfterPersist(model, instance.id)
        }

        return newInstance
    }

    /**
     * Send an update request (PUT) to the API with the given data on an existing model instance
     *
     * @param model
     * @param id
     * @param data
     */
    public async patch(model: typeof ApiModel, id: number, data: AssociativeArray) {
        let url = UrlUtils.join('api', model.entity, ''+id)

        const body = JSON.stringify(data)
        const response = await this.apiRequestService.put(url, body)

        const hydraResponse = await HydraDenormalizer.denormalize(response)
        return this.newInstance(model, hydraResponse.data)
    }

    /**
     * Delete the model instance from the datasource via the API
     *
     * @param model
     * @param instance
     */
    public async delete(model: typeof ApiModel, instance: ApiResource) {
        const repository = this.getRepository(model)

        // If object has been persisted to the datasource, send a delete request
        if (!instance.isNew()) {
            const url = UrlUtils.join('api', model.entity, String(instance.id))
            await this.apiRequestService.delete(url)
        }

        // reactiveUpdate the store
        repository.destroy(instance.id)
    }

    /**
     * Reset the model instance to its initial state (i.e. the state it had when it was fetched from the API)
     *
     * @param model
     * @param instance
     */
    public reset(model: typeof ApiResource, instance: ApiResource) {
        const initialInstance = this.getInitialStateOf(model, instance.id)
        if (initialInstance === null) {
            throw new Error('no initial state recorded for this object - abort [' + model.entity + '/' + instance.id + ']')
        }

        const repository = this.getRepository(model)
        repository.save(initialInstance)

        return initialInstance
    }

    /**
     * @todo: à déplacer dans le store directement
     * @Deprecated : a priori ce n'est pas le bon service pour mettre à jour le profil, on devrait voir ça
     * depuis un service dédié, un composable, ou directement dans le store ==> oui !
     *
     * Re-fetch the user profile and update the store
     */
    public async refreshProfile(accessId: number) {
        const profile = await this.fetch(MyProfile, accessId)

        // On met à jour le store accessProfile
        const accessProfile = useAccessProfileStore()
        accessProfile.setProfile(profile)
    }

    /**
     * Delete all records in the repository of the model
     *
     * @param model
     */
    public flush(model: typeof ApiModel) {
        const repository = this.getRepository(model)
        repository.flush()
    }

    /**
     * Is the model instance a new one, or does it already exist in the data source (=API)
     *
     * This is a convenient way of testing a model instance you did not already fetch, else prefer the use of the
     * isNew() method of ApiResource
     *
     * @param model
     * @param id
     */
    public isNewInstance(model: typeof ApiModel, id: number | string): boolean {
        const repository = this.getRepository(model)

        const item = repository.find(id)
        if (!item || typeof item === 'undefined') {
            // TODO: est-ce qu'il ne faudrait pas lever une erreur ici plutôt?
            console.error(model.entity + '/' + id + ' does not exist!')
            return false
        }

        return item.isNew()
    }

    /**
     * Save the state of the model instance in the store, so this state could be restored later
     *
     * @param model
     * @param instance
     * @private
     */
    protected saveInitialState(model: typeof ApiResource, instance: ApiResource) {
        const repository = this.getRepository(model)

        // Clone and prefix id
        const clone = _.cloneDeep(instance)
        clone.id = this.CLONE_PREFIX + clone.id

        repository.save(clone)
    }

    /**
     * Return the saved state of the model instance from the store
     *
     * @param model
     * @param id
     * @private
     */
    protected getInitialStateOf(model: typeof ApiResource, id: string | number): ApiResource | null {
        const repository = this.getRepository(model)

        // Find the clone by id
        const instance = repository.find(this.CLONE_PREFIX + id)
        if (instance === null) {
            return null
        }

        // Restore the initial id
        instance.id = id
        return instance
    }

    /**
     * Delete the temporary model instance from the repo after it was persisted via the api, replaced by the instance
     * that has been returned by the api with is definitive id.
     *
     * @param model
     * @param tempInstanceId
     * @private
     */
    protected removeTempAfterPersist(model: typeof ApiResource, tempInstanceId: number | string) {
        const repository = this.getRepository(model)

        const instance = repository.find(tempInstanceId)
        if (!instance || typeof instance === 'undefined') {
            // TODO: il vaudrait peut-être mieux lever une erreur ici?
            console.error(model.entity + '/' + tempInstanceId + ' does not exist!')
            return
        }
        if (!instance.isNew()) {
            throw new Error('Error: Can not remove a non-temporary model instance')
        }

        repository.destroy(tempInstanceId)
        repository.destroy(this.CLONE_PREFIX + tempInstanceId)
    }
}

export default EntityManager
