import { Injectable } from '@angular/core';
import {
  CordovaCheck,
  CordovaProperty,
  AwesomeCordovaNativePlugin,
  Plugin,
  getPromise,
} from '@awesome-cordova-plugins/core';

export interface IFile extends Blob {
  /**
   * Name of the file, without path information
   */
  name: string;
  /**
   * Last modified date
   */
  lastModified: number;
  /**
   * Last modified date
   */
  lastModifiedDate: number;
  /**
   * Size in bytes
   */
  size: number;
  /**
   * File mime type
   */
  type: string;
  localURL: string;
  start: number;
  end: number;

  /**
   * Returns a "slice" of the file. Since Cordova Files don't contain the actual
   * content, this really returns a File with adjusted start and end.
   * Slices of slices are supported.
   *
   * @param start {Number} The index at which to start the slice (inclusive).
   * @param end {Number} The index at which to end the slice (exclusive).
   */
  slice(start: number, end: number): Blob;
}

export interface LocalFileSystem {
  /**
   * Used for storage with no guarantee of persistence.
   */
  TEMPORARY: number;

  /**
   * Used for storage that should not be removed by the user agent without application or user permission.
   */
  PERSISTENT: number;

  /**
   * Requests a filesystem in which to store application data.
   *
   * @param type Whether the filesystem requested should be persistent, as defined above. Use one of TEMPORARY or
   *   PERSISTENT.
   * @param size This is an indicator of how much storage space, in bytes, the application expects to need.
   * @param successCallback The callback that is called when the user agent provides a filesystem.
   * @param errorCallback A callback that is called when errors happen, or when the request to obtain the filesystem is
   *   denied.
   */
  requestFileSystem(
    type: number,
    size: number,
    successCallback: FileSystemCallback,
    errorCallback?: ErrorCallback
  ): void;

  /**
   * Allows the user to look up the Entry for a file or directory referred to by a local URL.
   *
   * @param url A URL referring to a local file in a filesystem accessable via this API.
   * @param successCallback A callback that is called to report the FileEntry to which the supplied URL refers.
   * @param errorCallback A callback that is called when errors happen, or when the request to obtain the Entry is
   *   denied.
   */
  resolveLocalFileSystemURL(url: string, successCallback: FileEntryCallback, errorCallback?: ErrorCallback): void;

  /**
   * see requestFileSystem.
   */
  webkitRequestFileSystem(
    type: number,
    size: number,
    successCallback: FileSystemCallback,
    errorCallback?: ErrorCallback
  ): void;
}

export interface Metadata {
  /**
   * This is the time at which the file or directory was last modified.
   *
   * @readonly
   */
  modificationTime: Date;

  /**
   * The size of the file, in bytes. This must return 0 for directories.
   *
   * @readonly
   */
  size: number;
}

export interface Flags {
  /**
   * Used to indicate that the user wants to create a file or directory if it was not previously there.
   */
  create?: boolean;

  /**
   * By itself, exclusive must have no effect. Used with create, it must cause getFile and getDirectory to fail if the
   * target path already exists.
   */
  exclusive?: boolean;
}

/**
 * This export interface represents a file system.
 */
export interface FileSystem {
  /**
   * This is the name of the file system. The specifics of naming filesystems is unspecified, but a name must be unique
   * across the list of exposed file systems.
   *
   * @readonly
   */
  name: string;

  /**
   * The root directory of the file system.
   *
   * @readonly
   */
  root: DirectoryEntry;

  toJSON(): string;

  encodeURIPath(path: string): string;
}

export interface Entry {
  /**
   * Entry is a file.
   */
  isFile: boolean;

  /**
   * Entry is a directory.
   */
  isDirectory: boolean;

  /**
   * Look up metadata about this entry.
   *
   * @param successCallback A callback that is called with the time of the last modification.
   * @param errorCallback ErrorCallback A callback that is called when errors happen.
   */
  getMetadata(successCallback: MetadataCallback, errorCallback?: ErrorCallback): void;

  /**
   * Set the metadata of the entry.
   *
   * @param successCallback {Function} is called with a Metadata object
   * @param errorCallback {Function} is called with a FileError
   * @param metadataObject {Metadata} keys and values to set
   */
  setMetadata(successCallback: MetadataCallback, errorCallback: ErrorCallback, metadataObject: Metadata): void;

  /**
   * The name of the entry, excluding the path leading to it.
   */
  name: string;
  /**
   * The full absolute path from the root to the entry.
   */
  fullPath: string;
  /**
   * The file system on which the entry resides.
   */
  filesystem: FileSystem;
  /**
   * an alternate URL which can be used by native webview controls, for example media players.
   */
  nativeURL: string;

  /**
   * Move an entry to a different location on the file system. It is an error to try to:
   *
   * <ui>
   * <li>move a directory inside itself or to any child at any depth;</li>
   * <li>move an entry into its parent if a name different from its current one isn't provided;</li>
   * <li>move a file to a path occupied by a directory;</li>
   * <li>move a directory to a path occupied by a file;</li>
   * <li>move any element to a path occupied by a directory which is not empty.</li>
   * <ul>
   *
   * A move of a file on top of an existing file must attempt to delete and replace that file.
   * A move of a directory on top of an existing empty directory must attempt to delete and replace that directory.
   */
  moveTo(
    parent: DirectoryEntry,
    newName?: string,
    successCallback?: EntryCallback,
    errorCallback?: ErrorCallback
  ): void;

  /**
   * Copy an entry to a different location on the file system. It is an error to try to:
   *
   * <ul>
   * <li> copy a directory inside itself or to any child at any depth;</li>
   * <li> copy an entry into its parent if a name different from its current one isn't provided;</li>
   * <li> copy a file to a path occupied by a directory;</li>
   * <li> copy a directory to a path occupied by a file;</li>
   * <li> copy any element to a path occupied by a directory which is not empty.</li>
   * <li> A copy of a file on top of an existing file must attempt to delete and replace that file.</li>
   * <li> A copy of a directory on top of an existing empty directory must attempt to delete and replace that
   * directory.</li>
   * </ul>
   *
   * Directory copies are always recursive--that is, they copy all contents of the directory.
   */
  copyTo(
    parent: DirectoryEntry,
    newName?: string,
    successCallback?: EntryCallback,
    errorCallback?: ErrorCallback
  ): void;

  /**
   * Returns a URL that can be used to identify this entry. Unlike the URN defined in [FILE-API-ED], it has no specific
   * expiration; as it describes a location on disk, it should be valid at least as long as that location exists.
   */
  toURL(): string;

  /**
   * Return a URL that can be passed across the bridge to identify this entry.
   *
   * @returns string URL that can be passed across the bridge to identify this entry
   */
  toInternalURL(): string;

  /**
   * Deletes a file or directory. It is an error to attempt to delete a directory that is not empty. It is an error to
   * attempt to delete the root directory of a filesystem.
   *
   * @param successCallback A callback that is called on success.
   * @param errorCallback A callback that is called when errors happen.
   */
  remove(successCallback: VoidCallback, errorCallback?: ErrorCallback): void;

  /**
   * Look up the parent DirectoryEntry containing this Entry. If this Entry is the root of its filesystem, its parent
   * is itself.
   *
   * @param successCallback A callback that is called to return the parent Entry.
   * @param errorCallback A callback that is called when errors happen.
   */
  getParent(successCallback: DirectoryEntryCallback, errorCallback?: ErrorCallback): void;
}

/**
 * This export interface represents a directory on a file system.
 */
export interface DirectoryEntry extends Entry {
  /**
   * Creates a new DirectoryReader to read Entries from this Directory.
   */
  createReader(): DirectoryReader;

  /**
   * Creates or looks up a file.
   *
   * @param path Either an absolute path or a relative path from this DirectoryEntry to the file to be looked up or
   *   created. It is an error to attempt to create a file whose immediate parent does not yet exist.
   * @param options
   *     <ul>
   *     <li>If create and exclusive are both true, and the path already exists, getFile must fail.</li>
   *     <li>If create is true, the path doesn't exist, and no other error occurs, getFile must create it as a
   *   zero-length file and return a corresponding FileEntry.</li>
   *     <li>If create is not true and the path doesn't exist, getFile must fail.</li>
   *     <li>If create is not true and the path exists, but is a directory, getFile must fail.</li>
   *     <li>Otherwise, if no other error occurs, getFile must return a FileEntry corresponding to path.</li>
   *     </ul>
   * @param successCallback A callback that is called to return the File selected or created.
   * @param errorCallback A callback that is called when errors happen.
   */
  getFile(path: string, options?: Flags, successCallback?: FileEntryCallback, errorCallback?: ErrorCallback): void;

  /**
   * Creates or looks up a directory.
   *
   * @param path Either an absolute path or a relative path from this DirectoryEntry to the directory to be looked up
   *   or created. It is an error to attempt to create a directory whose immediate parent does not yet exist.
   * @param options
   *     <ul>
   *     <li>If create and exclusive are both true and the path already exists, getDirectory must fail.</li>
   *     <li>If create is true, the path doesn't exist, and no other error occurs, getDirectory must create and return
   *   a corresponding DirectoryEntry.</li>
   *     <li>If create is not true and the path doesn't exist, getDirectory must fail.</li>
   *     <li>If create is not true and the path exists, but is a file, getDirectory must fail.</li>
   *     <li>Otherwise, if no other error occurs, getDirectory must return a DirectoryEntry corresponding to path.</li>
   *     </ul>
   * @param successCallback   A callback that is called to return the DirectoryEntry selected or created.
   * @param errorCallback A callback that is called when errors happen.
   */
  getDirectory(
    path: string,
    options?: Flags,
    successCallback?: DirectoryEntryCallback,
    errorCallback?: ErrorCallback
  ): void;

  /**
   * Deletes a directory and all of its contents, if any. In the event of an error [e.g. trying to delete a directory
   * that contains a file that cannot be removed], some of the contents of the directory may be deleted. It is an error
   * to attempt to delete the root directory of a filesystem.
   *
   * @param successCallback A callback that is called on success.
   * @param errorCallback A callback that is called when errors happen.
   */
  removeRecursively(successCallback: VoidCallback, errorCallback?: ErrorCallback): void;
}

/**
 * This export interface lets a user list files and directories in a directory. If there are no additions to or
 * deletions from a directory between the first and last call to readEntries, and no errors occur, then:
 * <ul>
 * <li> A series of calls to readEntries must return each entry in the directory exactly once.</li>
 * <li> Once all entries have been returned, the next call to readEntries must produce an empty array.</li>
 * <li> If not all entries have been returned, the array produced by readEntries must not be empty.</li>
 * <li> The entries produced by readEntries must not include the directory itself ["."] or its parent [".."].</li>
 * </ul>
 */
export interface DirectoryReader {
  localURL: string;
  hasReadEntries: boolean;

  /**
   * Read the next block of entries from this directory.
   *
   * @param successCallback Called once per successful call to readEntries to deliver the next previously-unreported
   *   set of Entries in the associated Directory. If all Entries have already been returned from previous invocations
   *   of readEntries, successCallback must be called with a zero-length array as an argument.
   * @param errorCallback A callback indicating that there was an error reading from the Directory.
   */
  readEntries(successCallback: EntriesCallback, errorCallback?: ErrorCallback): void;
}

/**
 * This export interface represents a file on a file system.
 */
export interface FileEntry extends Entry {
  /**
   * Creates a new FileWriter associated with the file that this FileEntry represents.
   *
   * @param successCallback A callback that is called with the new FileWriter.
   * @param errorCallback A callback that is called when errors happen.
   */
  createWriter(successCallback: FileWriterCallback, errorCallback?: ErrorCallback): void;

  /**
   * Returns a File that represents the current state of the file that this FileEntry represents.
   *
   * @param successCallback A callback that is called with the File.
   * @param errorCallback A callback that is called when errors happen.
   */
  file(successCallback: FileCallback, errorCallback?: ErrorCallback): void;
}

/**
 * When requestFileSystem() succeeds, the following callback is made.
 */
export type FileSystemCallback = (filesystem: FileSystem) => void;

/**
 * This export interface is the callback used to look up Entry objects.
 */
export type EntryCallback = (entry: Entry) => void;

/**
 * This export interface is the callback used to look up FileEntry objects.
 */
export type FileEntryCallback = (entry: FileEntry) => void;

/**
 * This export interface is the callback used to look up DirectoryEntry objects.
 */
export type DirectoryEntryCallback = (entry: DirectoryEntry) => void;

/**
 * When readEntries() succeeds, the following callback is made.
 */
export type EntriesCallback = (entries: Entry[]) => void;

/**
 * This export interface is the callback used to look up file and directory metadata.
 */
export type MetadataCallback = (metadata: Metadata) => void;

/**
 * This export interface is the callback used to create a FileWriter.
 */
export type FileWriterCallback = (fileWriter: FileWriter) => void;

/**
 * This export interface is the callback used to obtain a File.
 */
export type FileCallback = (file: IFile) => void;

/**
 * This export interface is the generic callback used to indicate success of an asynchronous method.
 */
export type VoidCallback = () => void;

/**
 * When an error occurs, the following callback is made.
 */
export type ErrorCallback = (err: FileError) => void;

export interface RemoveResult {
  success: boolean;
  fileRemoved: Entry;
}

/** @hidden */
export declare class FileSaver extends EventTarget {
  /**
   * When the FileSaver constructor is called, the user agent must return a new FileSaver object with readyState set to
   * INIT. This constructor must be visible when the script's global object is either a Window object or an object
   * implementing the WorkerUtils interface.
   */
  constructor(data: Blob);

  /**
   * When the abort method is called, user agents must run the steps below:
   * <ol>
   * <li> If readyState == DONE or readyState == INIT, terminate this overall series of steps without doing anything
   * else. </li>
   * <li> Set readyState to DONE. </li>
   * <li> If there are any tasks from the object's FileSaver task source in one of the task queues, then remove those
   * tasks. </li>
   * <li> Terminate the write algorithm being processed. </li>
   * <li> Set the error attribute to a DOMError object of type "AbortError". </li>
   * <li> Fire a progress event called abort </li>
   * <li> Fire a progress event called write end </li>
   * <li> Terminate this algorithm. </li>
   * </ol>
   */
  abort(): void;

  /**
   * The blob is being written.
   *
   * @readonly
   */
  INIT: number;
  /**
   * The object has been constructed, but there is no pending write.
   *
   * @readonly
   */
  WRITING: number;
  /**
   * The entire Blob has been written to the file, an error occurred during the write, or the write was aborted using
   * abort(). The FileSaver is no longer writing the blob.
   *
   * @readonly
   */
  DONE: number;
  /**
   * The FileSaver object can be in one of 3 states. The readyState attribute, on getting, must return the current
   * state, which must be one of the following values:
   * <ul>
   * <li>INIT</li>
   * <li>WRITING</li>
   * <li>DONE</li>
   * <ul>
   *
   * @readonly
   */
  readyState: number;
  /**
   * The last error that occurred on the FileSaver.
   *
   * @readonly
   */
  error: Error;
  /**
   * Handler for write start events
   */
  onwritestart: (event: ProgressEvent) => void;
  /**
   * Handler for progress events.
   */
  onprogress: (event: ProgressEvent) => void;
  /**
   * Handler for write events.
   */
  onwrite: (event: ProgressEvent) => void;
  /**
   * Handler for abort events.
   */
  onabort: (event: ProgressEvent) => void;
  /**
   * Handler for error events.
   */
  onerror: (event: ProgressEvent) => void;
  /**
   * Handler for write end events.
   */
  onwriteend: (event: ProgressEvent) => void;
}

/**
 * @hidden
 * This interface expands on the FileSaver interface to allow for multiple write actions, rather than just saving a
 *   single Blob.
 */
export declare class FileWriter extends FileSaver {
  /**
   * The byte offset at which the next write to the file will occur. This must be no greater than length.
   * A newly-created FileWriter must have position set to 0.
   */
  position: number;

  /**
   * The length of the file. If the user does not have read access to the file, this must be the highest byte offset at
   * which the user has written.
   */
  length: number;

  /**
   * Write the supplied data to the file at position.
   *
   * @param data The blob to write.
   */
  write(data: ArrayBuffer | Blob | string): void;

  /**
   * Seek sets the file position at which the next write will occur.
   *
   * @param offset If nonnegative, an absolute byte offset into the file. If negative, an offset back from the end of
   *   the file.
   */
  seek(offset: number): void;

  /**
   * Changes the length of the file to that specified. If shortening the file, data beyond the new length must be
   * discarded. If extending the file, the existing data must be zero-padded up to the new length.
   *
   * @param size The size to which the length of the file is to be adjusted, measured in bytes.
   */
  truncate(size: number): void;
}

export interface IWriteOptions {
  replace?: boolean;
  append?: boolean;
  truncate?: number; // if present, number of bytes to truncate file to before writing
}

/** @hidden */
export declare class FileError {
  static NOT_FOUND_ERR: number;
  static SECURITY_ERR: number;
  static ABORT_ERR: number;
  static NOT_READABLE_ERR: number;
  static ENCODING_ERR: number;
  static NO_MODIFICATION_ALLOWED_ERR: number;
  static INVALID_STATE_ERR: number;
  static SYNTAX_ERR: number;
  static INVALID_MODIFICATION_ERR: number;
  static QUOTA_EXCEEDED_ERR: number;
  static TYPE_MISMATCH_ERR: number;
  static PATH_EXISTS_ERR: number;
  /** Error code */
  code: number;
  message: string;

  constructor(code: number);
}

/** @hidden */
export declare class FileReader {
  static EMPTY: number;
  static LOADING: number;
  static DONE: number;
  static READ_CHUNK_SIZE: number;

  readyState: number; // see constants in var declaration below
  error: Error;
  result: string | ArrayBuffer; // type depends on readAsXXX() call type

  onloadstart: (evt: ProgressEvent) => void;
  onprogress: (evt: ProgressEvent) => void;
  onload: (evt: ProgressEvent) => void;
  onerror: (evt: ProgressEvent) => void;
  onloadend: (evt: ProgressEvent) => void;
  onabort: (evt: ProgressEvent) => void;

  abort(): void;

  readAsText(fe: IFile, encoding?: string): void;

  readAsDataURL(fe: IFile): void;

  readAsBinaryString(fe: IFile): void;

  readAsArrayBuffer(fe: IFile): void;

  /**
   * @hidden
   */
  [key: string]: any;
}

type Window = LocalFileSystem;

declare const window: Window;

/**
 * @name File
 * @premier filesystem
 * @description
 * This plugin implements a File API allowing read/write access to files residing on the device.
 *
 * The File class implements static convenience functions to access files and directories.
 *
 * Example:
 * ```
 * import { File } from '@awesome-cordova-plugins/file/ngx';
 *
 * constructor(private file: File) { }
 *
 * ...
 *
 * this.file.checkDir(this.file.dataDirectory, 'mydir').then(_ => console.log('Directory exists')).catch(err =>
 *   console.log('Directory doesn't exist'));
 *
 * ```
 *
 *  This plugin is based on several specs, including : The HTML5 File API http: //www.w3.org/TR/FileAPI/
 *  The (now-defunct) Directories and System extensions Latest: http: //www.w3.org/TR/2012/WD-file-system-api-20120417/
 *  Although most of the plugin code was written when an earlier spec was current: http:
 *   //www.w3.org/TR/2011/WD-file-system-api-20110419/ It also implements the FileWriter spec : http:
 *   //dev.w3.org/2009/dap/file-system/file-writer.html
 *  @interfaces
 *  IFile
 *  Entry
 *  DirectoryEntry
 *  DirectoryReader
 *  FileSystem
 */
@Plugin({
  pluginName: 'File',
  plugin: 'cordova-plugin-file',
  pluginRef: 'cordova.file',
  repo: 'https://github.com/apache/cordova-plugin-file',
  platforms: ['Android', 'Browser', 'iOS', 'macOS', 'Windows'],
})
@Injectable()
export class File extends AwesomeCordovaNativePlugin {
  /**
   *  Read-only directory where the application is installed.
   */
  @CordovaProperty() applicationDirectory: string;

  /**
   *  Read-only directory where the application is installed.
   */
  @CordovaProperty() applicationStorageDirectory: string;

  /**
   * Where to put app-specific data files.
   */
  @CordovaProperty() dataDirectory: string;

  /**
   * Cached files that should survive app restarts.
   * Apps should not rely on the OS to delete files in here.
   */
  @CordovaProperty() cacheDirectory: string;

  /**
   * Android: the application space on external storage.
   */
  @CordovaProperty() externalApplicationStorageDirectory: string;

  /**
   *  Android: Where to put app-specific data files on external storage.
   */
  @CordovaProperty() externalDataDirectory: string;

  /**
   * Android: the application cache on external storage.
   */
  @CordovaProperty() externalCacheDirectory: string;

  /**
   * Android: the external storage (SD card) root.
   */
  @CordovaProperty() externalRootDirectory: string;

  /**
   * iOS: Temp directory that the OS can clear at will.
   */
  @CordovaProperty() tempDirectory: string;

  /**
   * iOS: Holds app-specific files that should be synced (e.g. to iCloud).
   */
  @CordovaProperty() syncedDataDirectory: string;

  /**
   * iOS: Files private to the app, but that are meaningful to other applications (e.g. Office files)
   */
  @CordovaProperty() documentsDirectory: string;

  /**
   * BlackBerry10: Files globally available to all apps
   */
  @CordovaProperty() sharedDirectory: string;

  cordovaFileError: any = {
    1: 'NOT_FOUND_ERR',
    2: 'SECURITY_ERR',
    3: 'ABORT_ERR',
    4: 'NOT_READABLE_ERR',
    5: 'ENCODING_ERR',
    6: 'NO_MODIFICATION_ALLOWED_ERR',
    7: 'INVALID_STATE_ERR',
    8: 'SYNTAX_ERR',
    9: 'INVALID_MODIFICATION_ERR',
    10: 'QUOTA_EXCEEDED_ERR',
    11: 'TYPE_MISMATCH_ERR',
    12: 'PATH_EXISTS_ERR',
    13: 'WRONG_ENTRY_TYPE',
    14: 'DIR_READ_ERR',
  };

  /**
   * Get free disk space in Bytes
   *
   * @returns {Promise<number>} Returns a promise that resolves with the remaining free disk space in Bytes
   */
  @CordovaCheck()
  getFreeDiskSpace(): Promise<number> {
    return getPromise<any>((resolve, reject) => {
      cordova.exec(resolve as (data: any) => any, reject as (data: any) => any, 'File', 'getFreeDiskSpace', []);
    });
  }

  /**
   * Check if a directory exists in a certain path, directory.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} dir Name of directory to check
   * @returns {Promise<boolean>} Returns a Promise that resolves to true if the directory exists or rejects with an
   *   error.
   */
  @CordovaCheck()
  checkDir(path: string, dir: string): Promise<boolean> {
    if (/^\//.test(dir)) {
      const err = new FileError(5);
      err.message = 'directory cannot start with /';
      return Promise.reject<any>(err);
    }

    const fullPath = path + dir;
    return this.resolveDirectoryUrl(fullPath).then(() => {
      return true;
    });
  }

  /**
   * Creates a new directory in the specific path.
   * The replace boolean value determines whether to replace an existing directory with the same name.
   * If an existing directory exists and the replace value is false, the promise will fail and return an error.
   *
   * @param {string} path  Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} dirName Name of directory to create
   * @param {boolean} replace If true, replaces file with same name. If false returns error
   * @returns {Promise<DirectoryEntry>} Returns a Promise that resolves with a DirectoryEntry or rejects with an error.
   */
  @CordovaCheck()
  createDir(path: string, dirName: string, replace: boolean): Promise<DirectoryEntry> {
    if (/^\//.test(dirName)) {
      const err = new FileError(5);
      err.message = 'directory cannot start with /';
      return Promise.reject<any>(err);
    }

    const options: Flags = {
      create: true,
    };

    if (!replace) {
      options.exclusive = true;
    }

    return this.resolveDirectoryUrl(path).then((fse) => {
      return this.getDirectory(fse, dirName, options);
    });
  }

  /**
   * Remove a directory at a given path.
   *
   * @param {string} path The path to the directory
   * @param {string} dirName The directory name
   * @returns {Promise<RemoveResult>} Returns a Promise that resolves to a RemoveResult or rejects with an error.
   */
  @CordovaCheck()
  removeDir(path: string, dirName: string): Promise<RemoveResult> {
    if (/^\//.test(dirName)) {
      const err = new FileError(5);
      err.message = 'directory cannot start with /';
      return Promise.reject<any>(err);
    }

    return this.resolveDirectoryUrl(path)
      .then((fse) => {
        return this.getDirectory(fse, dirName, { create: false });
      })
      .then((de) => {
        return this.remove(de);
      });
  }

  /**
   * Move a directory to a given path.
   *
   * @param {string} path The source path to the directory
   * @param {string} dirName The source directory name
   * @param {string} newPath The destination path to the directory
   * @param {string} newDirName The destination directory name
   * @returns {Promise<DirectoryEntry|Entry>} Returns a Promise that resolves to the new DirectoryEntry object or
   *   rejects with an error.
   */
  @CordovaCheck()
  moveDir(path: string, dirName: string, newPath: string, newDirName: string): Promise<DirectoryEntry | Entry> {
    newDirName = newDirName || dirName;

    if (/^\//.test(newDirName)) {
      const err = new FileError(5);
      err.message = 'directory cannot start with /';
      return Promise.reject<any>(err);
    }

    return this.resolveDirectoryUrl(path)
      .then((fse) => {
        return this.getDirectory(fse, dirName, { create: false });
      })
      .then((srcde) => {
        return this.resolveDirectoryUrl(newPath).then((destenation) => {
          return this.move(srcde, destenation, newDirName);
        });
      });
  }

  /**
   * Copy a directory in various methods. If destination directory exists, will fail to copy.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystems above
   * @param {string} dirName Name of directory to copy
   * @param {string} newPath Base FileSystem of new location
   * @param {string} newDirName New name of directory to copy to (leave blank to remain the same)
   * @returns {Promise<Entry>} Returns a Promise that resolves to the new Entry object or rejects with an error.
   */
  @CordovaCheck()
  copyDir(path: string, dirName: string, newPath: string, newDirName: string): Promise<Entry> {
    if (/^\//.test(newDirName)) {
      const err = new FileError(5);
      err.message = 'directory cannot start with /';
      return Promise.reject<any>(err);
    }

    return this.resolveDirectoryUrl(path)
      .then((fse) => {
        return this.getDirectory(fse, dirName, { create: false });
      })
      .then((srcde) => {
        return this.resolveDirectoryUrl(newPath).then((deste) => {
          return this.copy(srcde, deste, newDirName);
        });
      });
  }

  /**
   * List files and directory from a given path.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystems above
   * @param {string} dirName Name of directory
   * @returns {Promise<Entry[]>} Returns a Promise that resolves to an array of Entry objects or rejects with an error.
   */
  @CordovaCheck()
  listDir(path: string, dirName: string): Promise<Entry[]> {
    if (/^\//.test(dirName)) {
      const err = new FileError(5);
      err.message = 'directory cannot start with /';
      return Promise.reject<Entry[]>(err);
    }

    return this.resolveDirectoryUrl(path)
      .then((fse) => {
        return this.getDirectory(fse, dirName, {
          create: false,
          exclusive: false,
        });
      })
      .then((de) => {
        const reader = de.createReader();
        return this.readEntries(reader);
      });
  }

  /**
   * Removes all files and the directory from a desired location.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} dirName Name of directory
   * @returns {Promise<RemoveResult>} Returns a Promise that resolves with a RemoveResult or rejects with an error.
   */
  @CordovaCheck()
  removeRecursively(path: string, dirName: string): Promise<RemoveResult> {
    if (/^\//.test(dirName)) {
      const err = new FileError(5);
      err.message = 'directory cannot start with /';
      return Promise.reject<RemoveResult>(err);
    }

    return this.resolveDirectoryUrl(path)
      .then((fse) => {
        return this.getDirectory(fse, dirName, { create: false });
      })
      .then((de) => {
        return this.rimraf(de);
      });
  }

  /**
   * Check if a file exists in a certain path, directory.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} file Name of file to check
   * @returns {Promise<boolean>} Returns a Promise that resolves with a boolean or rejects with an error.
   */
  @CordovaCheck()
  checkFile(path: string, file: string): Promise<boolean> {
    if (/^\//.test(file)) {
      const err = new FileError(5);
      err.message = 'file cannot start with /';
      return Promise.reject<any>(err);
    }

    return this.resolveLocalFilesystemUrl(path + file).then((fse) => {
      if (fse.isFile) {
        return true;
      } else {
        const err = new FileError(13);
        err.message = 'input is not a file';
        return Promise.reject<boolean>(err);
      }
    });
  }

  /**
   * Creates a new file in the specific path.
   * The replace boolean value determines whether to replace an existing file with the same name.
   * If an existing file exists and the replace value is false, the promise will fail and return an error.
   *
   * @param {string} path  Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} fileName Name of file to create
   * @param {boolean} replace If true, replaces file with same name. If false returns error
   * @returns {Promise<FileEntry>} Returns a Promise that resolves to a FileEntry or rejects with an error.
   */
  @CordovaCheck()
  createFile(path: string, fileName: string, replace: boolean): Promise<FileEntry> {
    if (/^\//.test(fileName)) {
      const err = new FileError(5);
      err.message = 'file-name cannot start with /';
      return Promise.reject<any>(err);
    }

    const options: Flags = {
      create: true,
    };

    if (!replace) {
      options.exclusive = true;
    }

    return this.resolveDirectoryUrl(path).then((fse) => {
      return this.getFile(fse, fileName, options);
    });
  }

  /**
   * Removes a file from a desired location.
   *
   * @param {string} path  Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} fileName Name of file to remove
   * @returns {Promise<RemoveResult>} Returns a Promise that resolves to a RemoveResult or rejects with an error.
   */
  @CordovaCheck()
  removeFile(path: string, fileName: string): Promise<RemoveResult> {
    if (/^\//.test(fileName)) {
      const err = new FileError(5);
      err.message = 'file-name cannot start with /';
      return Promise.reject<any>(err);
    }

    return this.resolveDirectoryUrl(path)
      .then((fse) => {
        return this.getFile(fse, fileName, { create: false });
      })
      .then((fe) => {
        return this.remove(fe);
      });
  }

  /**
   * Write a new file to the desired location.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} fileName path relative to base path
   * @param {string | Blob | ArrayBuffer} text content, blob or ArrayBuffer to write
   * @param {IWriteOptions} whether to replace/append to an existing file. See IWriteOptions for more information.
   * @param options
   * @returns {Promise<any>} Returns a Promise that resolves to updated file entry or rejects with an error.
   */
  @CordovaCheck()
  writeFile(
    path: string,
    fileName: string,
    text: string | Blob | ArrayBuffer,
    options: IWriteOptions = {}
  ): Promise<any> {
    if (/^\//.test(fileName)) {
      const err = new FileError(5);
      err.message = 'file-name cannot start with /';
      return Promise.reject(err);
    }

    const getFileOpts: Flags = {
      create: !options.append,
      exclusive: !options.replace,
    };

    return this.resolveDirectoryUrl(path)
      .then((directoryEntry: DirectoryEntry) => {
        return this.getFile(directoryEntry, fileName, getFileOpts);
      })
      .then((fileEntry: FileEntry) => {
        return this.writeFileEntry(fileEntry, text, options);
      });
  }

  /**
   * Write content to FileEntry.
   *
   * @hidden
   * Write to an existing file.
   * @param {FileEntry} fe file entry object
   * @param {string | Blob | ArrayBuffer} text text content or blob to write
   * @param {IWriteOptions} options replace file if set to true. See WriteOptions for more information.
   * @returns {Promise<FileEntry>}  Returns a Promise that resolves to updated file entry or rejects with an error.
   */
  private writeFileEntry(fe: FileEntry, text: string | Blob | ArrayBuffer, options: IWriteOptions) {
    return this.createWriter(fe)
      .then((writer) => {
        if (options.append) {
          writer.seek(writer.length);
        }

        if (options.truncate) {
          writer.truncate(options.truncate);
        }

        return this.write(writer, text);
      })
      .then(() => fe);
  }

  /**
   * Write to an existing file.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} fileName path relative to base path
   * @param {string | Blob} text content or blob to write
   * @returns {Promise<void>} Returns a Promise that resolves or rejects with an error.
   */
  @CordovaCheck()
  writeExistingFile(path: string, fileName: string, text: string | Blob): Promise<void> {
    return this.writeFile(path, fileName, text, { replace: true });
  }

  /**
   * Read the contents of a file as text.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} file Name of file, relative to path.
   * @returns {Promise<string>} Returns a Promise that resolves with the contents of the file as string or rejects with
   *   an error.
   */
  @CordovaCheck()
  readAsText(path: string, file: string): Promise<string> {
    return this.readFile<string>(path, file, 'Text');
  }

  /**
   * Read file and return data as a base64 encoded data url.
   * A data url is of the form:
   *      data: [<mediatype>][;base64],<data>
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} file Name of file, relative to path.
   * @returns {Promise<string>} Returns a Promise that resolves with the contents of the file as data URL or rejects
   *   with an error.
   */
  @CordovaCheck()
  readAsDataURL(path: string, file: string): Promise<string> {
    return this.readFile<string>(path, file, 'DataURL');
  }

  /**
   * Read file and return data as a binary data.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} file Name of file, relative to path.
   * @returns {Promise<string>} Returns a Promise that resolves with the contents of the file as string rejects with an
   *   error.
   */
  @CordovaCheck()
  readAsBinaryString(path: string, file: string): Promise<string> {
    return this.readFile<string>(path, file, 'BinaryString');
  }

  /**
   * Read file and return data as an ArrayBuffer.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} file Name of file, relative to path.
   * @returns {Promise<ArrayBuffer>} Returns a Promise that resolves with the contents of the file as ArrayBuffer or
   *   rejects with an error.
   */
  @CordovaCheck()
  readAsArrayBuffer(path: string, file: string): Promise<ArrayBuffer> {
    return this.readFile<ArrayBuffer>(path, file, 'ArrayBuffer');
  }

  /**
   * Move a file to a given path.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} fileName Name of file to move
   * @param {string} newPath Base FileSystem of new location
   * @param {string} newFileName New name of file to move to (leave blank to remain the same)
   * @returns {Promise<Entry>} Returns a Promise that resolves to the new Entry or rejects with an error.
   */
  @CordovaCheck()
  moveFile(path: string, fileName: string, newPath: string, newFileName: string): Promise<Entry> {
    newFileName = newFileName || fileName;

    if (/^\//.test(newFileName)) {
      const err = new FileError(5);
      err.message = 'file name cannot start with /';
      return Promise.reject<any>(err);
    }

    return this.resolveDirectoryUrl(path)
      .then((fse) => {
        return this.getFile(fse, fileName, { create: false });
      })
      .then((srcfe) => {
        return this.resolveDirectoryUrl(newPath).then((deste) => {
          return this.move(srcfe, deste, newFileName);
        });
      });
  }

  /**
   * Copy a file in various methods. If file exists, will fail to copy.
   *
   * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystem above
   * @param {string} fileName Name of file to copy
   * @param {string} newPath Base FileSystem of new location
   * @param {string} newFileName New name of file to copy to (leave blank to remain the same)
   * @returns {Promise<Entry>} Returns a Promise that resolves to an Entry or rejects with an error.
   */
  @CordovaCheck()
  copyFile(path: string, fileName: string, newPath: string, newFileName: string): Promise<Entry> {
    newFileName = newFileName || fileName;

    if (/^\//.test(newFileName)) {
      const err = new FileError(5);
      err.message = 'file name cannot start with /';
      return Promise.reject<any>(err);
    }

    return this.resolveDirectoryUrl(path)
      .then((fse) => {
        return this.getFile(fse, fileName, { create: false });
      })
      .then((srcfe) => {
        return this.resolveDirectoryUrl(newPath).then((deste) => {
          return this.copy(srcfe, deste, newFileName);
        });
      });
  }

  /**
   * @param err
   * @hidden
   */
  private fillErrorMessage(err: FileError): void {
    try {
      err.message = this.cordovaFileError[err.code];
    } catch (e) {}
  }

  /**
   * Resolves a local file system URL
   *
   * @param fileUrl {string} file system url
   * @returns {Promise<Entry>}
   */
  @CordovaCheck()
  resolveLocalFilesystemUrl(fileUrl: string): Promise<Entry> {
    return getPromise<Entry>((resolve, reject) => {
      try {
        window.resolveLocalFileSystemURL(
          fileUrl,
          (entry: Entry) => {
            resolve(entry);
          },
          (err) => {
            this.fillErrorMessage(err);
            reject(err);
          }
        );
      } catch (xc) {
        this.fillErrorMessage(xc);
        reject(xc);
      }
    });
  }

  /**
   * Resolves a local directory url
   *
   * @param directoryUrl {string} directory system url
   * @returns {Promise<DirectoryEntry>}
   */
  @CordovaCheck()
  resolveDirectoryUrl(directoryUrl: string): Promise<DirectoryEntry> {
    return this.resolveLocalFilesystemUrl(directoryUrl).then((de) => {
      if (de.isDirectory) {
        return de as DirectoryEntry;
      } else {
        const err = new FileError(13);
        err.message = 'input is not a directory';
        return Promise.reject<DirectoryEntry>(err);
      }
    });
  }

  /**
   * Get a directory
   *
   * @param directoryEntry {DirectoryEntry} Directory entry, obtained by resolveDirectoryUrl method
   * @param directoryName {string} Directory name
   * @param flags {Flags} Options
   * @returns {Promise<DirectoryEntry>}
   */
  @CordovaCheck()
  getDirectory(directoryEntry: DirectoryEntry, directoryName: string, flags: Flags): Promise<DirectoryEntry> {
    return new Promise<DirectoryEntry>((resolve, reject) => {
      try {
        directoryEntry.getDirectory(
          directoryName,
          flags,
          (de) => {
            resolve(de);
          },
          (err) => {
            this.fillErrorMessage(err);
            reject(err);
          }
        );
      } catch (xc) {
        this.fillErrorMessage(xc);
        reject(xc);
      }
    });
  }

  /**
   * Get a file
   *
   * @param directoryEntry {DirectoryEntry} Directory entry, obtained by resolveDirectoryUrl method
   * @param fileName {string} File name
   * @param flags {Flags} Options
   * @returns {Promise<FileEntry>}
   */
  @CordovaCheck()
  getFile(directoryEntry: DirectoryEntry, fileName: string, flags: Flags): Promise<FileEntry> {
    return new Promise<FileEntry>((resolve, reject) => {
      try {
        directoryEntry.getFile(fileName, flags, resolve, (err) => {
          this.fillErrorMessage(err);
          reject(err);
        });
      } catch (xc) {
        this.fillErrorMessage(xc);
        reject(xc);
      }
    });
  }

  private readFile<T>(
    path: string,
    file: string,
    readAs: 'ArrayBuffer' | 'BinaryString' | 'DataURL' | 'Text'
  ): Promise<T> {
    if (/^\//.test(file)) {
      const err = new FileError(5);
      err.message = 'file-name cannot start with /';
      return Promise.reject<any>(err);
    }

    return this.resolveDirectoryUrl(path)
      .then((directoryEntry: DirectoryEntry) => {
        return this.getFile(directoryEntry, file, { create: false });
      })
      .then((fileEntry: FileEntry) => {
        const reader = new FileReader();
        return getPromise<T>((resolve, reject) => {
          reader.onloadend = () => {
            if (reader.result !== undefined || reader.result !== null) {
              resolve(reader.result as any as T);
            } else if (reader.error !== undefined || reader.error !== null) {
              reject(reader.error);
            } else {
              reject({ code: null, message: 'READER_ONLOADEND_ERR' });
            }
          };

          fileEntry.file(
            (file) => {
              reader[`readAs${readAs}`].call(reader, file);
            },
            (error) => {
              reject(error);
            }
          );
        });
      });
  }

  /**
   * @param fe
   * @hidden
   */
  private remove(fe: Entry): Promise<RemoveResult> {
    return new Promise<RemoveResult>((resolve, reject) => {
      fe.remove(
        () => {
          resolve({ success: true, fileRemoved: fe });
        },
        (err) => {
          this.fillErrorMessage(err);
          reject(err);
        }
      );
    });
  }

  /**
   * @param srce
   * @param destdir
   * @param newName
   * @hidden
   */
  private move(srce: Entry, destdir: DirectoryEntry, newName: string): Promise<Entry> {
    return new Promise<Entry>((resolve, reject) => {
      srce.moveTo(
        destdir,
        newName,
        (deste) => {
          resolve(deste);
        },
        (err) => {
          this.fillErrorMessage(err);
          reject(err);
        }
      );
    });
  }

  /**
   * @param srce
   * @param destdir
   * @param newName
   * @hidden
   */
  private copy(srce: Entry, destdir: DirectoryEntry, newName: string): Promise<Entry> {
    return new Promise<Entry>((resolve, reject) => {
      srce.copyTo(
        destdir,
        newName,
        (deste) => {
          resolve(deste);
        },
        (err) => {
          this.fillErrorMessage(err);
          reject(err);
        }
      );
    });
  }

  /**
   * @param dr
   * @hidden
   */
  private readEntries(dr: DirectoryReader): Promise<Entry[]> {
    return new Promise<Entry[]>((resolve, reject) => {
      dr.readEntries(
        (entries) => {
          resolve(entries);
        },
        (err) => {
          this.fillErrorMessage(err);
          reject(err);
        }
      );
    });
  }

  /**
   * @param de
   * @hidden
   */
  private rimraf(de: DirectoryEntry): Promise<RemoveResult> {
    return new Promise<RemoveResult>((resolve, reject) => {
      de.removeRecursively(
        () => {
          resolve({ success: true, fileRemoved: de });
        },
        (err) => {
          this.fillErrorMessage(err);
          reject(err);
        }
      );
    });
  }

  /**
   * @param fe
   * @hidden
   */
  private createWriter(fe: FileEntry): Promise<FileWriter> {
    return new Promise<FileWriter>((resolve, reject) => {
      fe.createWriter(
        (writer) => {
          resolve(writer);
        },
        (err) => {
          this.fillErrorMessage(err);
          reject(err);
        }
      );
    });
  }

  /**
   * @param writer
   * @param gu
   * @hidden
   */
  private write(writer: FileWriter, gu: string | Blob | ArrayBuffer): Promise<any> {
    if (gu instanceof Blob) {
      return this.writeFileInChunks(writer, gu);
    }

    return new Promise<any>((resolve, reject) => {
      writer.onwriteend = (evt) => {
        if (writer.error) {
          reject(writer.error);
        } else {
          resolve(evt);
        }
      };
      writer.write(gu);
    });
  }

  /**
   * @param writer
   * @param file
   * @hidden
   */
  private writeFileInChunks(writer: FileWriter, file: Blob) {
    const BLOCK_SIZE = 1024 * 1024;
    let writtenSize = 0;

    /**
     *
     */
    function writeNextChunk() {
      const size = Math.min(BLOCK_SIZE, file.size - writtenSize);
      const chunk = file.slice(writtenSize, writtenSize + size);

      writtenSize += size;
      writer.write(chunk);
    }

    return getPromise<any>((resolve, reject) => {
      writer.onerror = reject as (event: ProgressEvent) => void;
      writer.onwrite = () => {
        if (writtenSize < file.size) {
          writeNextChunk();
        } else {
          resolve();
        }
      };
      writeNextChunk();
    });
  }
}
