/*
 * Copyright (c) 2015-present, Parse, LLC.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */
package com.parse;

import android.os.Parcel;
import android.os.Parcelable;
import com.parse.boltsinternal.Continuation;
import com.parse.boltsinternal.Task;
import com.parse.boltsinternal.TaskCompletionSource;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.Callable;
import org.json.JSONException;
import org.json.JSONObject;

/**
 * {@code ParseFile} is a local representation of a file that is saved to the Parse cloud.
 *
 * <p>The workflow is to construct a {@code ParseFile} with data and optionally a filename. Then
 * save it and set it as a field on a {@link ParseObject}.
 *
 * <p>Example:
 *
 * <pre>
 * ParseFile file = new ParseFile("hello".getBytes());
 * file.save();
 *
 * ParseObject object = new ParseObject("TestObject");
 * object.put("file", file);
 * object.save();
 * </pre>
 */
public class ParseFile implements Parcelable {

    public static final Creator<ParseFile> CREATOR =
            new Creator<ParseFile>() {
                @Override
                public ParseFile createFromParcel(Parcel source) {
                    return new ParseFile(source);
                }

                @Override
                public ParseFile[] newArray(int size) {
                    return new ParseFile[size];
                }
            };
    /* package for tests */ final TaskQueue taskQueue = new TaskQueue();
    private final Set<TaskCompletionSource<?>> currentTasks =
            Collections.synchronizedSet(new HashSet<>());
    /**
     * Staging of {@code ParseFile}'s data is stored in memory until the {@code ParseFile} has been
     * successfully synced with the server.
     */
    /* package for tests */ byte[] data;
    /* package for tests */ File file;
    private State state;

    /**
     * Creates a new file from a file pointer.
     *
     * @param file The file.
     */
    public ParseFile(File file) {
        this(file, null);
    }

    /**
     * Creates a new file from a file pointer, and content type. Content type will be used instead
     * of auto-detection by file extension.
     *
     * @param file The file.
     * @param contentType The file's content type.
     */
    public ParseFile(File file, String contentType) {
        this(new State.Builder().name(file.getName()).mimeType(contentType).build());
        this.file = file;
    }

    /**
     * Creates a new file from a byte array, file name, and content type. Content type will be used
     * instead of auto-detection by file extension.
     *
     * @param name The file's name, ideally with extension. The file name must begin with an
     *     alphanumeric character, and consist of alphanumeric characters, periods, spaces,
     *     underscores, or dashes.
     * @param data The file's data.
     * @param contentType The file's content type.
     */
    public ParseFile(String name, byte[] data, String contentType) {
        this(new State.Builder().name(name).mimeType(contentType).build());
        this.data = data;
    }

    /**
     * Creates a new file from a byte array.
     *
     * @param data The file's data.
     */
    public ParseFile(byte[] data) {
        this(null, data, null);
    }

    /**
     * Creates a new file from a byte array and a name. Giving a name with a proper file extension
     * (e.g. ".png") is ideal because it allows Parse to deduce the content type of the file and set
     * appropriate HTTP headers when it is fetched.
     *
     * @param name The file's name, ideally with extension. The file name must begin with an
     *     alphanumeric character, and consist of alphanumeric characters, periods, spaces,
     *     underscores, or dashes.
     * @param data The file's data.
     */
    public ParseFile(String name, byte[] data) {
        this(name, data, null);
    }

    /**
     * Creates a new file from a byte array, and content type. Content type will be used instead of
     * auto-detection by file extension.
     *
     * @param data The file's data.
     * @param contentType The file's content type.
     */
    public ParseFile(byte[] data, String contentType) {
        this(null, data, contentType);
    }

    /**
     * Creates a new file instance from a {@link Parcel} source. This is used when unparceling a
     * non-dirty ParseFile. Subclasses that need Parcelable behavior should provide their own {@link
     * android.os.Parcelable.Creator} and override this constructor.
     *
     * @param source the source Parcel
     */
    protected ParseFile(Parcel source) {
        this(source, ParseParcelDecoder.get());
    }

    /**
     * Creates a new file instance from a {@link Parcel} using the given {@link ParseParcelDecoder}.
     * The decoder is currently unused, but it might be in the future, plus this is the pattern we
     * are using in parcelable classes.
     *
     * @param source the parcel
     * @param decoder the decoder
     */
    ParseFile(Parcel source, ParseParcelDecoder decoder) {
        this(
                new State.Builder()
                        .url(source.readString())
                        .name(source.readString())
                        .mimeType(source.readByte() == 1 ? source.readString() : null)
                        .build());
    }

    /* package for tests */ ParseFile(State state) {
        this.state = state;
    }

    /*
     * Encode/Decode
     */
    @SuppressWarnings("unused")
    /* package */ ParseFile(JSONObject json, ParseDecoder decoder) {
        this(new State.Builder().name(json.optString("name")).url(json.optString("url")).build());
    }

    /* package for tests */
    static ParseFileController getFileController() {
        return ParseCorePlugins.getInstance().getFileController();
    }

    private static ProgressCallback progressCallbackOnMainThread(
            final ProgressCallback progressCallback) {
        if (progressCallback == null) {
            return null;
        }

        return percentDone ->
                Task.call(
                        (Callable<Void>)
                                () -> {
                                    progressCallback.done(percentDone);
                                    return null;
                                },
                        ParseExecutors.main());
    }

    /* package for tests */ State getState() {
        return state;
    }

    /**
     * The filename. Before save is called, this is just the filename given by the user (if any).
     * After save is called, that name gets prefixed with a unique identifier.
     *
     * @return The file's name.
     */
    public String getName() {
        return state.name();
    }

    /**
     * Whether the file still needs to be saved.
     *
     * @return Whether the file needs to be saved.
     */
    public boolean isDirty() {
        return state.url() == null;
    }

    /** Whether the file has available data. */
    public boolean isDataAvailable() {
        return data != null || getFileController().isDataAvailable(state);
    }

    /**
     * This returns the url of the file. It's only available after you save or after you get the
     * file from a ParseObject.
     *
     * @return The url of the file.
     */
    public String getUrl() {
        return state.url();
    }

    /** Saves the file to the Parse cloud synchronously. */
    public void save() throws ParseException {
        ParseTaskUtils.wait(saveInBackground());
    }

    private Task<Void> saveAsync(
            final String sessionToken,
            final ProgressCallback uploadProgressCallback,
            Task<Void> toAwait,
            final Task<Void> cancellationToken) {
        // If the file isn't dirty, just return immediately.
        if (!isDirty()) {
            return Task.forResult(null);
        }
        if (cancellationToken != null && cancellationToken.isCancelled()) {
            return Task.cancelled();
        }

        // Wait for our turn in the queue, then check state to decide whether to no-op.
        return toAwait.continueWithTask(
                task -> {
                    if (!isDirty()) {
                        return Task.forResult(null);
                    }
                    if (cancellationToken != null && cancellationToken.isCancelled()) {
                        return Task.cancelled();
                    }

                    Task<State> saveTask;
                    if (data != null) {
                        saveTask =
                                getFileController()
                                        .saveAsync(
                                                state,
                                                data,
                                                sessionToken,
                                                progressCallbackOnMainThread(
                                                        uploadProgressCallback),
                                                cancellationToken);
                    } else {
                        saveTask =
                                getFileController()
                                        .saveAsync(
                                                state,
                                                file,
                                                sessionToken,
                                                progressCallbackOnMainThread(
                                                        uploadProgressCallback),
                                                cancellationToken);
                    }

                    return saveTask.onSuccessTask(
                            task1 -> {
                                state = task1.getResult();
                                // Since we have successfully uploaded the file, we do not need to
                                // hold the file pointer
                                // anymore.
                                data = null;
                                file = null;
                                return task1.makeVoid();
                            });
                });
    }

    /**
     * Saves the file to the Parse cloud in a background thread. `progressCallback` is guaranteed to
     * be called with 100 before saveCallback is called.
     *
     * @param uploadProgressCallback A ProgressCallback that is called periodically with progress
     *     updates.
     * @return A Task that will be resolved when the save completes.
     */
    public Task<Void> saveInBackground(final ProgressCallback uploadProgressCallback) {
        final TaskCompletionSource<Void> cts = new TaskCompletionSource<>();
        currentTasks.add(cts);

        return ParseUser.getCurrentSessionTokenAsync()
                .onSuccessTask(
                        task -> {
                            final String sessionToken = task.getResult();
                            return saveAsync(sessionToken, uploadProgressCallback, cts.getTask());
                        })
                .continueWithTask(
                        task -> {
                            cts.trySetResult(null); // release
                            currentTasks.remove(cts);
                            return task;
                        });
    }

    /* package */ Task<Void> saveAsync(
            final String sessionToken,
            final ProgressCallback uploadProgressCallback,
            final Task<Void> cancellationToken) {
        return taskQueue.enqueue(
                toAwait ->
                        saveAsync(
                                sessionToken, uploadProgressCallback, toAwait, cancellationToken));
    }

    /**
     * Saves the file to the Parse cloud in a background thread.
     *
     * @return A Task that will be resolved when the save completes.
     */
    public Task<Void> saveInBackground() {
        return saveInBackground((ProgressCallback) null);
    }

    /**
     * Saves the file to the Parse cloud in a background thread. `progressCallback` is guaranteed to
     * be called with 100 before saveCallback is called.
     *
     * @param saveCallback A SaveCallback that gets called when the save completes.
     * @param progressCallback A ProgressCallback that is called periodically with progress updates.
     */
    public void saveInBackground(
            final SaveCallback saveCallback, ProgressCallback progressCallback) {
        ParseTaskUtils.callbackOnMainThreadAsync(saveInBackground(progressCallback), saveCallback);
    }

    /**
     * Saves the file to the Parse cloud in a background thread.
     *
     * @param callback A SaveCallback that gets called when the save completes.
     */
    public void saveInBackground(SaveCallback callback) {
        ParseTaskUtils.callbackOnMainThreadAsync(saveInBackground(), callback);
    }

    /**
     * Synchronously gets the data from cache if available or fetches its content from the network.
     * You probably want to use {@link #getDataInBackground()} instead unless you're already in a
     * background thread.
     */
    public byte[] getData() throws ParseException {
        return ParseTaskUtils.wait(getDataInBackground());
    }

    /**
     * Asynchronously gets the data from cache if available or fetches its content from the network.
     * A {@code ProgressCallback} will be called periodically with progress updates.
     *
     * @param progressCallback A {@code ProgressCallback} that is called periodically with progress
     *     updates.
     * @return A Task that is resolved when the data has been fetched.
     */
    public Task<byte[]> getDataInBackground(final ProgressCallback progressCallback) {
        final TaskCompletionSource<Void> cts = new TaskCompletionSource<>();
        currentTasks.add(cts);

        return taskQueue
                .enqueue(
                        toAwait ->
                                fetchInBackground(progressCallback, toAwait, cts.getTask())
                                        .onSuccess(
                                                task -> {
                                                    File file = task.getResult();
                                                    try {
                                                        return ParseFileUtils.readFileToByteArray(
                                                                file);
                                                    } catch (IOException e) {
                                                        // do nothing
                                                    }
                                                    return null;
                                                }))
                .continueWithTask(
                        task -> {
                            cts.trySetResult(null); // release
                            currentTasks.remove(cts);
                            return task;
                        });
    }

    /**
     * Asynchronously gets the data from cache if available or fetches its content from the network.
     *
     * @return A Task that is resolved when the data has been fetched.
     */
    public Task<byte[]> getDataInBackground() {
        return getDataInBackground((ProgressCallback) null);
    }

    /**
     * Asynchronously gets the data from cache if available or fetches its content from the network.
     * A {@code ProgressCallback} will be called periodically with progress updates. A {@code
     * GetDataCallback} will be called when the get completes.
     *
     * @param dataCallback A {@code GetDataCallback} that is called when the get completes.
     * @param progressCallback A {@code ProgressCallback} that is called periodically with progress
     *     updates.
     */
    public void getDataInBackground(
            GetDataCallback dataCallback, final ProgressCallback progressCallback) {
        ParseTaskUtils.callbackOnMainThreadAsync(
                getDataInBackground(progressCallback), dataCallback);
    }

    /**
     * Asynchronously gets the data from cache if available or fetches its content from the network.
     * A {@code GetDataCallback} will be called when the get completes.
     *
     * @param dataCallback A {@code GetDataCallback} that is called when the get completes.
     */
    public void getDataInBackground(GetDataCallback dataCallback) {
        ParseTaskUtils.callbackOnMainThreadAsync(getDataInBackground(), dataCallback);
    }

    /**
     * Synchronously gets the file pointer from cache if available or fetches its content from the
     * network. You probably want to use {@link #getFileInBackground()} instead unless you're
     * already in a background thread. <strong>Note: </strong> The {@link File} location may change
     * without notice and should not be stored to be accessed later.
     */
    public File getFile() throws ParseException {
        return ParseTaskUtils.wait(getFileInBackground());
    }

    /**
     * Asynchronously gets the file pointer from cache if available or fetches its content from the
     * network. The {@code ProgressCallback} will be called periodically with progress updates.
     * <strong>Note: </strong> The {@link File} location may change without notice and should not be
     * stored to be accessed later.
     *
     * @param progressCallback A {@code ProgressCallback} that is called periodically with progress
     *     updates.
     * @return A Task that is resolved when the file pointer of this object has been fetched.
     */
    public Task<File> getFileInBackground(final ProgressCallback progressCallback) {
        final TaskCompletionSource<Void> cts = new TaskCompletionSource<>();
        currentTasks.add(cts);

        return taskQueue
                .enqueue(toAwait -> fetchInBackground(progressCallback, toAwait, cts.getTask()))
                .continueWithTask(
                        task -> {
                            cts.trySetResult(null); // release
                            currentTasks.remove(cts);
                            return task;
                        });
    }

    /**
     * Asynchronously gets the file pointer from cache if available or fetches its content from the
     * network. <strong>Note: </strong> The {@link File} location may change without notice and
     * should not be stored to be accessed later.
     *
     * @return A Task that is resolved when the data has been fetched.
     */
    public Task<File> getFileInBackground() {
        return getFileInBackground((ProgressCallback) null);
    }

    /**
     * Asynchronously gets the file pointer from cache if available or fetches its content from the
     * network. The {@code GetFileCallback} will be called when the get completes. The {@code
     * ProgressCallback} will be called periodically with progress updates. The {@code
     * ProgressCallback} is guaranteed to be called with 100 before the {@code GetFileCallback} is
     * called. <strong>Note: </strong> The {@link File} location may change without notice and
     * should not be stored to be accessed later.
     *
     * @param fileCallback A {@code GetFileCallback} that is called when the get completes.
     * @param progressCallback A {@code ProgressCallback} that is called periodically with progress
     *     updates.
     */
    public void getFileInBackground(
            GetFileCallback fileCallback, final ProgressCallback progressCallback) {
        ParseTaskUtils.callbackOnMainThreadAsync(
                getFileInBackground(progressCallback), fileCallback);
    }

    /**
     * Asynchronously gets the file pointer from cache if available or fetches its content from the
     * network. The {@code GetFileCallback} will be called when the get completes. <strong>Note:
     * </strong> The {@link File} location may change without notice and should not be stored to be
     * accessed later.
     *
     * @param fileCallback A {@code GetFileCallback} that is called when the get completes.
     */
    public void getFileInBackground(GetFileCallback fileCallback) {
        ParseTaskUtils.callbackOnMainThreadAsync(getFileInBackground(), fileCallback);
    }

    /**
     * Synchronously gets the data stream from cached file if available or fetches its content from
     * the network, saves the content as cached file and returns the data stream of the cached file.
     * You probably want to use {@link #getDataStreamInBackground} instead unless you're already in
     * a background thread.
     */
    public InputStream getDataStream() throws ParseException {
        return ParseTaskUtils.wait(getDataStreamInBackground());
    }

    /**
     * Asynchronously gets the data stream from cached file if available or fetches its content from
     * the network, saves the content as cached file and returns the data stream of the cached file.
     * The {@code ProgressCallback} will be called periodically with progress updates.
     *
     * @param progressCallback A {@code ProgressCallback} that is called periodically with progress
     *     updates.
     * @return A Task that is resolved when the data stream of this object has been fetched.
     */
    public Task<InputStream> getDataStreamInBackground(final ProgressCallback progressCallback) {
        final TaskCompletionSource<Void> cts = new TaskCompletionSource<>();
        currentTasks.add(cts);

        return taskQueue
                .enqueue(
                        (Continuation<Void, Task<InputStream>>)
                                toAwait ->
                                        fetchInBackground(progressCallback, toAwait, cts.getTask())
                                                .onSuccess(
                                                        task ->
                                                                new FileInputStream(
                                                                        task.getResult())))
                .continueWithTask(
                        task -> {
                            cts.trySetResult(null); // release
                            currentTasks.remove(cts);
                            return task;
                        });
    }

    /**
     * Asynchronously gets the data stream from cached file if available or fetches its content from
     * the network, saves the content as cached file and returns the data stream of the cached file.
     *
     * @return A Task that is resolved when the data stream has been fetched.
     */
    public Task<InputStream> getDataStreamInBackground() {
        return getDataStreamInBackground((ProgressCallback) null);
    }

    /**
     * Asynchronously gets the data stream from cached file if available or fetches its content from
     * the network, saves the content as cached file and returns the data stream of the cached file.
     * The {@code GetDataStreamCallback} will be called when the get completes. The {@code
     * ProgressCallback} will be called periodically with progress updates. The {@code
     * ProgressCallback} is guaranteed to be called with 100 before {@code GetDataStreamCallback} is
     * called.
     *
     * @param dataStreamCallback A {@code GetDataStreamCallback} that is called when the get
     *     completes.
     * @param progressCallback A {@code ProgressCallback} that is called periodically with progress
     *     updates.
     */
    public void getDataStreamInBackground(
            GetDataStreamCallback dataStreamCallback, final ProgressCallback progressCallback) {
        ParseTaskUtils.callbackOnMainThreadAsync(
                getDataStreamInBackground(progressCallback), dataStreamCallback);
    }

    /**
     * Asynchronously gets the data stream from cached file if available or fetches its content from
     * the network, saves the content as cached file and returns the data stream of the cached file.
     * The {@code GetDataStreamCallback} will be called when the get completes.
     *
     * @param dataStreamCallback A {@code GetDataStreamCallback} that is called when the get
     *     completes.
     */
    public void getDataStreamInBackground(GetDataStreamCallback dataStreamCallback) {
        ParseTaskUtils.callbackOnMainThreadAsync(getDataStreamInBackground(), dataStreamCallback);
    }

    private Task<File> fetchInBackground(
            final ProgressCallback progressCallback,
            Task<Void> toAwait,
            final Task<Void> cancellationToken) {
        if (cancellationToken != null && cancellationToken.isCancelled()) {
            return Task.cancelled();
        }

        return toAwait.continueWithTask(
                task -> {
                    if (cancellationToken != null && cancellationToken.isCancelled()) {
                        return Task.cancelled();
                    }
                    return getFileController()
                            .fetchAsync(
                                    state,
                                    null,
                                    progressCallbackOnMainThread(progressCallback),
                                    cancellationToken);
                });
    }

    /**
     * Cancels the operations for this {@code ParseFile} if they are still in the task queue.
     * However, if a network request has already been started for an operation, the network request
     * will not be canceled.
     */
    // TODO (grantland): Deprecate and replace with CancellationToken
    public void cancel() {
        Set<TaskCompletionSource<?>> tasks = new HashSet<>(currentTasks);
        for (TaskCompletionSource<?> tcs : tasks) {
            tcs.trySetCancelled();
        }
        currentTasks.removeAll(tasks);
    }

    /* package */ JSONObject encode() throws JSONException {
        JSONObject json = new JSONObject();
        json.put("__type", "File");
        json.put("name", getName());

        String url = getUrl();
        if (url == null) {
            throw new IllegalStateException("Unable to encode an unsaved ParseFile.");
        }
        json.put("url", getUrl());

        return json;
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        writeToParcel(dest, ParseParcelEncoder.get());
    }

    void writeToParcel(Parcel dest, ParseParcelEncoder encoder) {
        if (isDirty()) {
            throw new RuntimeException("Unable to parcel an unsaved ParseFile.");
        }
        dest.writeString(getUrl()); // Not null
        dest.writeString(getName()); // Not null
        String type = state.mimeType(); // Nullable
        dest.writeByte(type != null ? (byte) 1 : 0);
        if (type != null) {
            dest.writeString(type);
        }
    }

    /* package */ static class State {

        private final String name;
        private final String contentType;
        private final String url;

        private State(Builder builder) {
            name = builder.name != null ? builder.name : "file";
            contentType = builder.mimeType;
            url = builder.url;
        }

        public String name() {
            return name;
        }

        public String mimeType() {
            return contentType;
        }

        public String url() {
            return url;
        }

        /* package */ static class Builder {

            private String name;
            private String mimeType;
            private String url;

            public Builder() {
                // do nothing
            }

            public Builder(State state) {
                name = state.name();
                mimeType = state.mimeType();
                url = state.url();
            }

            public Builder name(String name) {
                this.name = name;
                return this;
            }

            public Builder mimeType(String mimeType) {
                this.mimeType = mimeType;
                return this;
            }

            public Builder url(String url) {
                this.url = url;
                return this;
            }

            public State build() {
                return new State(this);
            }
        }
    }
}
