/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.dlc;

import android.content.Context;
import android.net.Uri;
import android.util.Log;

import com.keepsafe.switchboard.SwitchBoard;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentBuilder;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import org.mozilla.gecko.Experiments;
import org.mozilla.gecko.util.IOUtils;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;

/**
 * Sync: Synchronize catalog from a Kinto instance.
 */
public class SyncAction extends BaseAction {
    private static final String LOGTAG = "DLCSyncAction";

    private static final String KINTO_KEY_ID = "id";
    private static final String KINTO_KEY_DELETED = "deleted";
    private static final String KINTO_KEY_DATA = "data";
    private static final String KINTO_KEY_ATTACHMENT = "attachment";
    private static final String KINTO_KEY_ORIGINAL = "original";

    private static final String KINTO_PARAMETER_SINCE = "_since";
    private static final String KINTO_PARAMETER_FIELDS = "_fields";
    private static final String KINTO_PARAMETER_SORT = "_sort";

    /**
     * Kinto endpoint with online version of downloadable content catalog
     *
     * Dev instance:
     * https://kinto-ota.dev.mozaws.net/v1/buckets/dlc/collections/catalog/records
     */
    private static final String CATALOG_ENDPOINT = "https://firefox.settings.services.mozilla.com/v1/buckets/fennec/collections/catalog/records";

    @Override
    public void perform(Context context, DownloadContentCatalog catalog) {
        Log.d(LOGTAG, "Synchronizing catalog.");

        if (!isSyncEnabledForClient(context)) {
            Log.d(LOGTAG, "Sync is not enabled for client. Skipping.");
            return;
        }

        boolean cleanupRequired = false;
        boolean studyRequired = false;

        try {
            long lastModified = catalog.getLastModified();

            // TODO: Consider using ETag here (Bug 1257459)
            JSONArray rawCatalog = fetchRawCatalog(lastModified);

            Log.d(LOGTAG, "Server returned " + rawCatalog.length() + " records (since " + lastModified + ")");

            for (int i = 0; i < rawCatalog.length(); i++) {
                JSONObject object = rawCatalog.getJSONObject(i);
                String id = object.getString(KINTO_KEY_ID);

                final boolean isDeleted = object.optBoolean(KINTO_KEY_DELETED, false);

                if (!isDeleted) {
                    JSONObject attachment = object.getJSONObject(KINTO_KEY_ATTACHMENT);
                    if (attachment.isNull(KINTO_KEY_ORIGINAL))
                        throw new JSONException(String.format("Old Attachment Format"));
                }

                DownloadContent existingContent = catalog.getContentById(id);

                if (isDeleted) {
                    cleanupRequired |= deleteContent(catalog, id);
                } else if (existingContent != null) {
                    studyRequired |= updateContent(catalog, object, existingContent);
                } else {
                    studyRequired |= createContent(catalog, object);
                }
            }
        } catch (UnrecoverableDownloadContentException e) {
            Log.e(LOGTAG, "UnrecoverableDownloadContentException", e);
        } catch (RecoverableDownloadContentException e) {
            Log.e(LOGTAG, "RecoverableDownloadContentException");
        } catch (JSONException e) {
            Log.e(LOGTAG, "JSONException", e);
        }

        if (studyRequired) {
            startStudyAction(context);
        }

        if (cleanupRequired) {
            startCleanupAction(context);
        }

        Log.v(LOGTAG, "Done");
    }

    protected void startStudyAction(Context context) {
        DownloadContentService.startStudy(context);
    }

    protected void startCleanupAction(Context context) {
        DownloadContentService.startCleanup(context);
    }

    protected JSONArray fetchRawCatalog(long lastModified)
            throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
        HttpURLConnection connection = null;

        try {
            Uri.Builder builder = Uri.parse(CATALOG_ENDPOINT).buildUpon();

            if (lastModified > 0) {
                builder.appendQueryParameter(KINTO_PARAMETER_SINCE, String.valueOf(lastModified));
            }
            // Only select the fields we are actually going to read.
            builder.appendQueryParameter(KINTO_PARAMETER_FIELDS,
                    "attachment.location,attachment.original.filename,attachment.original.hash,attachment.hash,type,kind,attachment.original.size,match");

            // We want to process items in the order they have been modified. This is to ensure that
            // our last_modified values are correct if we processing is interrupted and not all items
            // have been processed.
            builder.appendQueryParameter(KINTO_PARAMETER_SORT, "last_modified");

            connection = buildHttpURLConnection(builder.build().toString());

            // TODO: Read 'Alert' header and EOL message if existing (Bug 1249248)

            // TODO: Read and use 'Backoff' header if available (Bug 1249251)

            // TODO: Add support for Next-Page header (Bug 1257495)

            final int responseCode = connection.getResponseCode();

            if (responseCode != HttpURLConnection.HTTP_OK) {
                if (responseCode >= 500) {
                    // A Retry-After header will be added to error responses (>=500), telling the
                    // client how many seconds it should wait before trying again.

                    // TODO: Read and obey value in "Retry-After" header (Bug 1249249)
                    throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER, "Server error (" + responseCode + ")");
                } else if (responseCode == 410) {
                    // A 410 Gone error response can be returned if the client version is too old,
                    // or the service had been replaced with a new and better service using a new
                    // protocol version.

                    // TODO: The server is gone. Stop synchronizing the catalog from this server (Bug 1249248).
                    throw new UnrecoverableDownloadContentException("Server is gone (410)");
                } else if (responseCode >= 400) {
                    // If the HTTP status is >=400 the response contains a JSON response.
                    logErrorResponse(connection);

                    // Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed.
                    throw new UnrecoverableDownloadContentException("(Unrecoverable) Catalog sync failed. Status code: " + responseCode);
                } else if (responseCode < 200) {
                    // If the HTTP status is <200 the response contains a JSON response.
                    logErrorResponse(connection);

                    throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER,  "Response code: " + responseCode);
                } else {
                    // HttpsUrlConnection: -1 (No valid response code)
                    // Successful 2xx: We don't know how to handle anything but 200.
                    // Redirection 3xx: We should have followed redirects if possible. We should not see those errors here.

                    throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Response code: " + responseCode);
                }
            }

            return fetchJSONResponse(connection).getJSONArray(KINTO_KEY_DATA);
        } catch (JSONException | IOException e) {
            throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e);
        } finally {
            if (connection != null) {
                connection.disconnect();
            }
        }
    }

    private JSONObject fetchJSONResponse(HttpURLConnection connection) throws IOException, JSONException {
        InputStream inputStream = null;

        try {
            inputStream = new BufferedInputStream(connection.getInputStream());
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            IOUtils.copy(inputStream, outputStream);
            return new JSONObject(outputStream.toString("UTF-8"));
        } finally {
            IOUtils.safeStreamClose(inputStream);
        }
    }

    protected boolean updateContent(DownloadContentCatalog catalog, JSONObject object, DownloadContent existingContent)
            throws JSONException {
        DownloadContent content = existingContent.buildUpon()
                .updateFromKinto(object)
                .build();

        if (existingContent.getLastModified() >= content.getLastModified()) {
            Log.d(LOGTAG, "Item has not changed: " + content);
            return false;
        }

        catalog.update(content);

        return true;
    }

    protected boolean createContent(DownloadContentCatalog catalog, JSONObject object) throws JSONException {
        DownloadContent content = new DownloadContentBuilder()
                .updateFromKinto(object)
                .build();

        catalog.add(content);

        return true;
    }

    protected boolean deleteContent(DownloadContentCatalog catalog, String id) {
        DownloadContent content = catalog.getContentById(id);
        if (content == null) {
            return false;
        }

        catalog.markAsDeleted(content);

        return true;
    }

    protected boolean isSyncEnabledForClient(Context context) {
        // Sync action is behind a switchboard flag for staged rollout.
        return SwitchBoard.isInExperiment(context, Experiments.DOWNLOAD_CONTENT_CATALOG_SYNC);
    }

    private void logErrorResponse(HttpURLConnection connection) {
        try {
            JSONObject error = fetchJSONResponse(connection);

            Log.w(LOGTAG, "Server returned error response:");
            Log.w(LOGTAG, "- Code:    " + error.getInt("code"));
            Log.w(LOGTAG, "- Errno:   " + error.getInt("errno"));
            Log.w(LOGTAG, "- Error:   " + error.optString("error", "-"));
            Log.w(LOGTAG, "- Message: " + error.optString("message", "-"));
            Log.w(LOGTAG, "- Info:    " + error.optString("info", "-"));
        } catch (JSONException | IOException e) {
            Log.w(LOGTAG, "Could not fetch error response", e);
        }
    }
}
