// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.cookies;

import android.content.Context;
import android.os.AsyncTask;

import org.chromium.base.ImportantFileWriterAndroid;
import org.chromium.base.Log;
import org.chromium.base.ThreadUtils;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.content.browser.crypto.CipherFactory;

import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;

/**
 * Responsible for fetching, (de)serializing, and restoring cookies between the CookieJar and an
 * encrypted file storage.
 */
public class CookiesFetcher {
    /** The default file name for the encrypted cookies storage. */
    private static final String DEFAULT_COOKIE_FILE_NAME = "COOKIES.DAT";

    /** Used for logging. */
    private static final String TAG = "CookiesFetcher";

    /** Native-side pointer. */
    private final long mNativeCookiesFetcher;

    private final Context mContext;

    /**
     * Creates a new fetcher that can use to fetch cookies from cookie jar
     * or from a file.
     *
     * The lifetime of this object is handled internally. Callers only call
     * the public static methods which construct a CookiesFetcher object.
     * It remains alive only during the static call or when it is still
     * waiting for a callback to be invoked. In the latter case, the native
     * counter part will hold a strong reference to this Java class so the GC
     * would not collect it until the callback has been invoked.
     */
    private CookiesFetcher(Context context) {
        // Native side is responsible for destroying itself under all code paths.
        mNativeCookiesFetcher = nativeInit();
        mContext = context.getApplicationContext();
    }

    /**
     * Fetches the cookie file's path on demand to prevent IO on the main thread.
     *
     * @return Path to the cookie file.
     */
    private static String fetchFileName(Context context) {
        assert !ThreadUtils.runningOnUiThread();
        return context.getFileStreamPath(DEFAULT_COOKIE_FILE_NAME).getAbsolutePath();
    }

    /**
     * Asynchronously fetches cookies from the incognito profile and saves them to a file.
     *
     * @param context Context for accessing the file system.
     */
    public static void persistCookies(Context context) {
        try {
            new CookiesFetcher(context).persistCookiesInternal();
        } catch (RuntimeException e) {
            e.printStackTrace();
        }
    }

    private void persistCookiesInternal() {
        nativePersistCookies(mNativeCookiesFetcher);
    }

    /**
     * If an incognito profile exists, synchronously fetch cookies from the file specified and
     * populate the incognito profile with it.  Otherwise deletes the file and does not restore the
     * cookies.
     *
     * @param context Context for accessing the file system.
     */
    public static void restoreCookies(Context context) {
        try {
            if (deleteCookiesIfNecessary(context)) return;
            restoreCookiesInternal(context);
        } catch (RuntimeException e) {
            e.printStackTrace();
        }
    }

    private static void restoreCookiesInternal(final Context context) {
        new AsyncTask<Void, Void, List<CanonicalCookie>>() {
            @Override
            protected List<CanonicalCookie> doInBackground(Void... voids) {
                // Read cookies from disk on a background thread to avoid strict mode violations.
                List<CanonicalCookie> cookies = new ArrayList<CanonicalCookie>();
                DataInputStream in = null;
                try {
                    Cipher cipher = CipherFactory.getInstance().getCipher(Cipher.DECRYPT_MODE);
                    if (cipher == null) {
                        // Something is wrong. Can't encrypt, don't restore cookies.
                        return cookies;
                    }
                    File fileIn = new File(fetchFileName(context));
                    if (!fileIn.exists()) return cookies; // Nothing to read

                    FileInputStream streamIn = new FileInputStream(fileIn);
                    in = new DataInputStream(new CipherInputStream(streamIn, cipher));
                    cookies = CanonicalCookie.readListFromStream(in);

                    // The Cookie File should not be restored again. It'll be overwritten
                    // on the next onPause.
                    scheduleDeleteCookiesFile(context);

                } catch (IOException e) {
                    Log.w(TAG, "IOException during Cookie Restore", e);
                } catch (Throwable t) {
                    Log.w(TAG, "Error restoring cookies.", t);
                } finally {
                    try {
                        if (in != null) in.close();
                    } catch (IOException e) {
                        Log.w(TAG, "IOException during Cooke Restore");
                    } catch (Throwable t) {
                        Log.w(TAG, "Error restoring cookies.", t);
                    }
                }
                return cookies;
            }

            @Override
            protected void onPostExecute(List<CanonicalCookie> cookies) {
                // We can only access cookies and profiles on the UI thread.
                for (CanonicalCookie cookie : cookies) {
                    nativeRestoreCookies(cookie.getName(), cookie.getValue(), cookie.getDomain(),
                            cookie.getPath(), cookie.getCreationDate(), cookie.getExpirationDate(),
                            cookie.getLastAccessDate(), cookie.isSecure(), cookie.isHttpOnly(),
                            cookie.getSameSite(), cookie.getPriority());
                }
            }
        }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
    }

    /**
     * Ensure the incognito cookies are deleted when the incognito profile is gone.
     *
     * @param context Context for accessing the file system.
     * @return Whether or not the cookies were deleted.
     */
    public static boolean deleteCookiesIfNecessary(Context context) {
        try {
            if (Profile.getLastUsedProfile().hasOffTheRecordProfile()) return false;
            scheduleDeleteCookiesFile(context);
        } catch (RuntimeException e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    /**
     * Delete the cookies file. Called when we detect that all incognito tabs have been closed.
     */
    private static void scheduleDeleteCookiesFile(final Context context) {
        new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(Void... voids) {
                File cookiesFile = new File(fetchFileName(context));
                if (cookiesFile.exists()) {
                    if (!cookiesFile.delete()) {
                        Log.e(TAG, "Failed to delete " + cookiesFile.getName());
                    }
                }
                return null;
            }
        }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
    }

    @CalledByNative
    private CanonicalCookie createCookie(String name, String value, String domain, String path,
            long creation, long expiration, long lastAccess, boolean secure, boolean httpOnly,
            int sameSite, int priority) {
        return new CanonicalCookie(name, value, domain, path, creation, expiration, lastAccess,
                secure, httpOnly, sameSite, priority);
    }

    @CalledByNative
    private void onCookieFetchFinished(final CanonicalCookie[] cookies) {
        // Cookies fetching requires operations with the profile and must be
        // done in the main thread. Once that is done, do the save to disk
        // part in {@link AsyncTask} to avoid strict mode violations.
        new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(Void... voids) {
                saveFetchedCookiesToDisk(cookies);
                return null;
            }
        }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
    }

    private void saveFetchedCookiesToDisk(CanonicalCookie[] cookies) {
        DataOutputStream out = null;
        try {
            Cipher cipher = CipherFactory.getInstance().getCipher(Cipher.ENCRYPT_MODE);
            if (cipher == null) {
                // Something is wrong. Can't encrypt, don't save cookies.
                return;
            }

            ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
            CipherOutputStream cipherOut =
                    new CipherOutputStream(byteOut, cipher);
            out = new DataOutputStream(cipherOut);
            CanonicalCookie.saveListToStream(out, cookies);
            out.close();
            ImportantFileWriterAndroid.writeFileAtomically(
                    fetchFileName(mContext), byteOut.toByteArray());
            out = null;
        } catch (IOException e) {
            Log.w(TAG, "IOException during Cookie Fetch");
        } catch (Throwable t) {
            Log.w(TAG, "Error storing cookies.", t);
        } finally {
            try {
                if (out != null) out.close();
            } catch (IOException e) {
                Log.w(TAG, "IOException during Cookie Fetch");
            }
        }
    }

    @CalledByNative
    private CanonicalCookie[] createCookiesArray(int size) {
        return new CanonicalCookie[size];
    }

    private native long nativeInit();
    private native void nativePersistCookies(long nativeCookiesFetcher);
    private static native void nativeRestoreCookies(String name, String value, String domain,
            String path, long creation, long expiration, long lastAccess, boolean secure,
            boolean httpOnly, int sameSite, int priority);
}