/**
 * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */
/**
 * @module upload/filerepository
 */
import { Plugin, PendingActions } from '@ckeditor/ckeditor5-core';
import { CKEditorError, Collection, ObservableMixin, logWarning, uid } from '@ckeditor/ckeditor5-utils';
import FileReader from './filereader';
/**
 * File repository plugin. A central point for managing file upload.
 *
 * To use it, first you need an upload adapter. Upload adapter's job is to handle communication with the server
 * (sending the file and handling server's response). You can use one of the existing plugins introducing upload adapters
 * (e.g. {@link module:easy-image/cloudservicesuploadadapter~CloudServicesUploadAdapter} or
 * {@link module:adapter-ckfinder/uploadadapter~CKFinderUploadAdapter}) or write your own one – see
 * the {@glink framework/guides/deep-dive/upload-adapter Custom image upload adapter deep-dive guide}.
 *
 * Then, you can use {@link module:upload/filerepository~FileRepository#createLoader `createLoader()`} and the returned
 * {@link module:upload/filerepository~FileLoader} instance to load and upload files.
 *
 * @extends module:core/plugin~Plugin
 */
export default class FileRepository extends Plugin {
    /**
     * @inheritDoc
     */
    static get pluginName() {
        return 'FileRepository';
    }
    /**
     * @inheritDoc
     */
    static get requires() {
        return [PendingActions];
    }
    /**
     * @inheritDoc
     */
    init() {
        /**
         * Collection of loaders associated with this repository.
         *
         * @member {module:utils/collection~Collection} #loaders
         */
        this.loaders = new Collection();
        // Keeps upload in a sync with pending actions.
        this.loaders.on('change', () => this._updatePendingAction());
        /**
         * Loaders mappings used to retrieve loaders references.
         *
         * @private
         * @member {Map<File|Promise, FileLoader>} #_loadersMap
         */
        this._loadersMap = new Map();
        /**
         * Reference to a pending action registered in a {@link module:core/pendingactions~PendingActions} plugin
         * while upload is in progress. When there is no upload then value is `null`.
         *
         * @private
         * @member {Object} #_pendingAction
         */
        this._pendingAction = null;
        /**
         * A factory function which should be defined before using `FileRepository`.
         *
         * It should return a new instance of {@link module:upload/filerepository~UploadAdapter} that will be used to upload files.
         * {@link module:upload/filerepository~FileLoader} instance associated with the adapter
         * will be passed to that function.
         *
         * For more information and example see {@link module:upload/filerepository~UploadAdapter}.
         *
         * @member {Function} #createUploadAdapter
         */
        /**
         * Number of bytes uploaded.
         *
         * @readonly
         * @observable
         * @member {Number} #uploaded
         */
        this.set('uploaded', 0);
        /**
         * Number of total bytes to upload.
         *
         * It might be different than the file size because of headers and additional data.
         * It contains `null` if value is not available yet, so it's better to use {@link #uploadedPercent} to monitor
         * the progress.
         *
         * @readonly
         * @observable
         * @member {Number|null} #uploadTotal
         */
        this.set('uploadTotal', null);
        /**
         * Upload progress in percents.
         *
         * @readonly
         * @observable
         * @member {Number} #uploadedPercent
         */
        this.bind('uploadedPercent').to(this, 'uploaded', this, 'uploadTotal', (uploaded, total) => {
            return total ? (uploaded / total * 100) : 0;
        });
    }
    /**
     * Returns the loader associated with specified file or promise.
     *
     * To get loader by id use `fileRepository.loaders.get( id )`.
     *
     * @param {File|Promise.<File>} fileOrPromise Native file or promise handle.
     * @returns {module:upload/filerepository~FileLoader|null}
     */
    getLoader(fileOrPromise) {
        return this._loadersMap.get(fileOrPromise) || null;
    }
    /**
     * Creates a loader instance for the given file.
     *
     * Requires {@link #createUploadAdapter} factory to be defined.
     *
     * @param {File|Promise.<File>} fileOrPromise Native File object or native Promise object which resolves to a File.
     * @returns {module:upload/filerepository~FileLoader|null}
     */
    createLoader(fileOrPromise) {
        if (!this.createUploadAdapter) {
            /**
             * You need to enable an upload adapter in order to be able to upload files.
             *
             * This warning shows up when {@link module:upload/filerepository~FileRepository} is being used
             * without {@link #createUploadAdapter defining an upload adapter}.
             *
             * **If you see this warning when using one of the {@glink installation/getting-started/predefined-builds
             * CKEditor 5 Builds}**
             * it means that you did not configure any of the upload adapters available by default in those builds.
             *
             * See the {@glink features/images/image-upload/image-upload comprehensive "Image upload overview"} to learn which upload
             * adapters are available in the builds and how to configure them.
             *
             * **If you see this warning when using a custom build** there is a chance that you enabled
             * a feature like {@link module:image/imageupload~ImageUpload},
             * or {@link module:image/imageupload/imageuploadui~ImageUploadUI} but you did not enable any upload adapter.
             * You can choose one of the existing upload adapters listed in the
             * {@glink features/images/image-upload/image-upload "Image upload overview"}.
             *
             * You can also implement your {@glink framework/guides/deep-dive/upload-adapter own image upload adapter}.
             *
             * @error filerepository-no-upload-adapter
             */
            logWarning('filerepository-no-upload-adapter');
            return null;
        }
        const loader = new FileLoader(Promise.resolve(fileOrPromise), this.createUploadAdapter);
        this.loaders.add(loader);
        this._loadersMap.set(fileOrPromise, loader);
        // Store also file => loader mapping so loader can be retrieved by file instance returned upon Promise resolution.
        if (fileOrPromise instanceof Promise) {
            loader.file
                .then(file => {
                this._loadersMap.set(file, loader);
            })
                // Every then() must have a catch().
                // File loader state (and rejections) are handled in read() and upload().
                // Also, see the "does not swallow the file promise rejection" test.
                .catch(() => { });
        }
        loader.on('change:uploaded', () => {
            let aggregatedUploaded = 0;
            for (const loader of this.loaders) {
                aggregatedUploaded += loader.uploaded;
            }
            this.uploaded = aggregatedUploaded;
        });
        loader.on('change:uploadTotal', () => {
            let aggregatedTotal = 0;
            for (const loader of this.loaders) {
                if (loader.uploadTotal) {
                    aggregatedTotal += loader.uploadTotal;
                }
            }
            this.uploadTotal = aggregatedTotal;
        });
        return loader;
    }
    /**
     * Destroys the given loader.
     *
     * @param {File|Promise|module:upload/filerepository~FileLoader} fileOrPromiseOrLoader File or Promise associated
     * with that loader or loader itself.
     */
    destroyLoader(fileOrPromiseOrLoader) {
        const loader = fileOrPromiseOrLoader instanceof FileLoader ? fileOrPromiseOrLoader : this.getLoader(fileOrPromiseOrLoader);
        loader._destroy();
        this.loaders.remove(loader);
        this._loadersMap.forEach((value, key) => {
            if (value === loader) {
                this._loadersMap.delete(key);
            }
        });
    }
    /**
     * Registers or deregisters pending action bound with upload progress.
     *
     * @private
     */
    _updatePendingAction() {
        const pendingActions = this.editor.plugins.get(PendingActions);
        if (this.loaders.length) {
            if (!this._pendingAction) {
                const t = this.editor.t;
                const getMessage = (value) => `${t('Upload in progress')} ${parseInt(value)}%.`;
                this._pendingAction = pendingActions.add(getMessage(this.uploadedPercent));
                this._pendingAction.bind('message').to(this, 'uploadedPercent', getMessage);
            }
        }
        else {
            pendingActions.remove(this._pendingAction);
            this._pendingAction = null;
        }
    }
}
/**
 * File loader class.
 *
 * It is used to control the process of reading the file and uploading it using the specified upload adapter.
 */
class FileLoader extends ObservableMixin() {
    /**
     * Creates a new instance of `FileLoader`.
     *
     * @param {Promise.<File>} filePromise A promise which resolves to a file instance.
     * @param {Function} uploadAdapterCreator The function which returns {@link module:upload/filerepository~UploadAdapter} instance.
     */
    constructor(filePromise, uploadAdapterCreator) {
        super();
        /**
         * Unique id of FileLoader instance.
         *
         * @readonly
         * @member {Number}
         */
        this.id = uid();
        /**
         * Additional wrapper over the initial file promise passed to this loader.
         *
         * @protected
         * @member {module:upload/filerepository~FilePromiseWrapper}
         */
        this._filePromiseWrapper = this._createFilePromiseWrapper(filePromise);
        /**
         * Adapter instance associated with this file loader.
         *
         * @private
         * @member {module:upload/filerepository~UploadAdapter}
         */
        this._adapter = uploadAdapterCreator(this);
        /**
         * FileReader used by FileLoader.
         *
         * @protected
         * @member {module:upload/filereader~FileReader}
         */
        this._reader = new FileReader();
        /**
         * Current status of FileLoader. It can be one of the following:
         *
         * * 'idle',
         * * 'reading',
         * * 'uploading',
         * * 'aborted',
         * * 'error'.
         *
         * When reading status can change in a following way:
         *
         * `idle` -> `reading` -> `idle`
         * `idle` -> `reading -> `aborted`
         * `idle` -> `reading -> `error`
         *
         * When uploading status can change in a following way:
         *
         * `idle` -> `uploading` -> `idle`
         * `idle` -> `uploading` -> `aborted`
         * `idle` -> `uploading` -> `error`
         *
         * @readonly
         * @observable
         * @member {String} #status
         */
        this.set('status', 'idle');
        /**
         * Number of bytes uploaded.
         *
         * @readonly
         * @observable
         * @member {Number} #uploaded
         */
        this.set('uploaded', 0);
        /**
         * Number of total bytes to upload.
         *
         * @readonly
         * @observable
         * @member {Number|null} #uploadTotal
         */
        this.set('uploadTotal', null);
        /**
         * Upload progress in percents.
         *
         * @readonly
         * @observable
         * @member {Number} #uploadedPercent
         */
        this.bind('uploadedPercent').to(this, 'uploaded', this, 'uploadTotal', (uploaded, total) => {
            return total ? (uploaded / total * 100) : 0;
        });
        /**
         * Response of the upload.
         *
         * @readonly
         * @observable
         * @member {Object|null} #uploadResponse
         */
        this.set('uploadResponse', null);
    }
    /**
     * A `Promise` which resolves to a `File` instance associated with this file loader.
     *
     * @type {Promise.<File|null>}
     */
    get file() {
        if (!this._filePromiseWrapper) {
            // Loader was destroyed, return promise which resolves to null.
            return Promise.resolve(null);
        }
        else {
            // The `this._filePromiseWrapper.promise` is chained and not simply returned to handle a case when:
            //
            //		* The `loader.file.then( ... )` is called by external code (returned promise is pending).
            //		* Then `loader._destroy()` is called (call is synchronous) which destroys the `loader`.
            //		* Promise returned by the first `loader.file.then( ... )` call is resolved.
            //
            // Returning `this._filePromiseWrapper.promise` will still resolve to a `File` instance so there
            // is an additional check needed in the chain to see if `loader` was destroyed in the meantime.
            return this._filePromiseWrapper.promise.then(file => this._filePromiseWrapper ? file : null);
        }
    }
    /**
     * Returns the file data. To read its data, you need for first load the file
     * by using the {@link module:upload/filerepository~FileLoader#read `read()`} method.
     *
     * @type {File|undefined}
     */
    get data() {
        return this._reader.data;
    }
    /**
     * Reads file using {@link module:upload/filereader~FileReader}.
     *
     * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-read-wrong-status` when status
     * is different than `idle`.
     *
     * Example usage:
     *
     *	fileLoader.read()
     *		.then( data => { ... } )
     *		.catch( err => {
     *			if ( err === 'aborted' ) {
     *				console.log( 'Reading aborted.' );
     *			} else {
     *				console.log( 'Reading error.', err );
     *			}
     *		} );
     *
     * @returns {Promise.<String>} Returns promise that will be resolved with read data. Promise will be rejected if error
     * occurs or if read process is aborted.
     */
    read() {
        if (this.status != 'idle') {
            /**
             * You cannot call read if the status is different than idle.
             *
             * @error filerepository-read-wrong-status
             */
            throw new CKEditorError('filerepository-read-wrong-status', this);
        }
        this.status = 'reading';
        return this.file
            .then(file => this._reader.read(file))
            .then(data => {
            // Edge case: reader was aborted after file was read - double check for proper status.
            // It can happen when image was deleted during its upload.
            if (this.status !== 'reading') {
                throw this.status;
            }
            this.status = 'idle';
            return data;
        })
            .catch(err => {
            if (err === 'aborted') {
                this.status = 'aborted';
                throw 'aborted';
            }
            this.status = 'error';
            throw this._reader.error ? this._reader.error : err;
        });
    }
    /**
     * Reads file using the provided {@link module:upload/filerepository~UploadAdapter}.
     *
     * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-upload-wrong-status` when status
     * is different than `idle`.
     * Example usage:
     *
     *	fileLoader.upload()
     *		.then( data => { ... } )
     *		.catch( e => {
     *			if ( e === 'aborted' ) {
     *				console.log( 'Uploading aborted.' );
     *			} else {
     *				console.log( 'Uploading error.', e );
     *			}
     *		} );
     *
     * @returns {Promise.<Object>} Returns promise that will be resolved with response data. Promise will be rejected if error
     * occurs or if read process is aborted.
     */
    upload() {
        if (this.status != 'idle') {
            /**
             * You cannot call upload if the status is different than idle.
             *
             * @error filerepository-upload-wrong-status
             */
            throw new CKEditorError('filerepository-upload-wrong-status', this);
        }
        this.status = 'uploading';
        return this.file
            .then(() => this._adapter.upload())
            .then(data => {
            this.uploadResponse = data;
            this.status = 'idle';
            return data;
        })
            .catch(err => {
            if (this.status === 'aborted') {
                throw 'aborted';
            }
            this.status = 'error';
            throw err;
        });
    }
    /**
     * Aborts loading process.
     */
    abort() {
        const status = this.status;
        this.status = 'aborted';
        if (!this._filePromiseWrapper.isFulfilled) {
            // Edge case: file loader is aborted before read() is called
            // so it might happen that no one handled the rejection of this promise.
            // See https://github.com/ckeditor/ckeditor5-upload/pull/100
            this._filePromiseWrapper.promise.catch(() => { });
            this._filePromiseWrapper.rejecter('aborted');
        }
        else if (status == 'reading') {
            this._reader.abort();
        }
        else if (status == 'uploading' && this._adapter.abort) {
            this._adapter.abort();
        }
        this._destroy();
    }
    /**
     * Performs cleanup.
     *
     * @internal
     */
    _destroy() {
        this._filePromiseWrapper = undefined;
        this._reader = undefined;
        this._adapter = undefined;
        this.uploadResponse = undefined;
    }
    /**
     * Wraps a given file promise into another promise giving additional
     * control (resolving, rejecting, checking if fulfilled) over it.
     *
     * @private
     * @param filePromise The initial file promise to be wrapped.
     * @returns {module:upload/filerepository~FilePromiseWrapper}
     */
    _createFilePromiseWrapper(filePromise) {
        const wrapper = {};
        wrapper.promise = new Promise((resolve, reject) => {
            wrapper.rejecter = reject;
            wrapper.isFulfilled = false;
            filePromise
                .then(file => {
                wrapper.isFulfilled = true;
                resolve(file);
            })
                .catch(err => {
                wrapper.isFulfilled = true;
                reject(err);
            });
        });
        return wrapper;
    }
}
