/*
 * Copyright 2018 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.google.ads.consent;

import android.content.ContentResolver;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.provider.Settings;
import android.support.annotation.VisibleForTesting;
import android.text.TextUtils;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.annotations.SerializedName;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;

/**
 * Utility methods for collecting consent from users.
 */
public class ConsentInformation {
    private static class ConsentInfoUpdateResponse {
        boolean success;
        String responseInfo;

        ConsentInfoUpdateResponse(boolean success, String responseInfo) {
            this.success = success;
            this.responseInfo = responseInfo;
        }
    }
    private static final String MOBILE_ADS_SERVER_URL =
            "https://adservice.google.com/getconfig/pubvendors";
    private static final String TAG = "ConsentInformation";
    private static final String PREFERENCES_FILE_KEY = "mobileads_consent";
    private static final String CONSENT_DATA_KEY = "consent_string";
    private static ConsentInformation instance;

    private final Context context;
    private List<String> testDevices;
    private String hashedDeviceId;
    private DebugGeography debugGeography;

    private ConsentInformation(Context context) {
        this.context = context.getApplicationContext();
        this.debugGeography = DebugGeography.DEBUG_GEOGRAPHY_DISABLED;
        this.testDevices = new ArrayList<String>();
        this.hashedDeviceId = getHashedDeviceId();
    }

    public static synchronized ConsentInformation getInstance(Context context) {
        if (instance == null) {
            instance = new ConsentInformation(context);
        }
        return instance;
    }

  protected String getHashedDeviceId() {
        ContentResolver contentResolver = context.getContentResolver();
        String androidId =
            contentResolver == null
                ? null
                : Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID);
        return md5(((androidId == null) || isEmulator()) ? "emulator" : androidId);
    }

    /** Return the MD5 hash of a string. */
    private String md5(String string) {
        // Old devices have a bug where OpenSSL can leave MessageDigest in a bad state, but trying
        // multiple times seems to clear it.
        for (int i = 0; i < 3 /** max attempts */; ++i) {
            try {
                MessageDigest md5 = MessageDigest.getInstance("MD5");
                md5.update(string.getBytes());
        return String.format("%032X", new BigInteger(1, md5.digest()));
            } catch (NoSuchAlgorithmException e) {
                // Try again.
            } catch (ArithmeticException ex) {
                return null;
            }
        }
        return null;
    }

    @VisibleForTesting
    protected void setHashedDeviceId(String hashedDeviceId) {
        this.hashedDeviceId = hashedDeviceId;
    }

    private boolean isEmulator() {
        return Build.FINGERPRINT.startsWith("generic")
            || Build.FINGERPRINT.startsWith("unknown")
            || Build.MODEL.contains("google_sdk")
            || Build.MODEL.contains("Emulator")
            || Build.MODEL.contains("Android SDK built for x86")
            || Build.MANUFACTURER.contains("Genymotion")
            || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
            || "google_sdk".equals(Build.PRODUCT);
    }

    /** Returns if the current device is a designated debug device. */
    public boolean isTestDevice() {
        return isEmulator() || this.testDevices.contains(this.hashedDeviceId);
    }

    /**
     * Registers a device as a test device. Test devices will respect debug geography settings to
     * enable easier testing. Test devices must be added individually so that debug geography
     * settings won't accidentally get released to all users.
     * <p>You can access the hashedDeviceId from logcat once your app calls
     * requestConsentInfoUpdate.</p>
     *
     * @param hashedDeviceId The hashed device id that should be considered a debug device.
     */
    public void addTestDevice(String hashedDeviceId) {
        this.testDevices.add(hashedDeviceId);
    }

    /** Returns the location that's set for testing purposes. */
    public DebugGeography getDebugGeography() {
        return this.debugGeography;
    }

    /**
     * Sets the location of the device for testing purposes.
     *
     * @param debugGeography The location to be used for testing purposes.
     */
    public void setDebugGeography(DebugGeography debugGeography) {
        this.debugGeography = debugGeography;
    }

    private static class AdNetworkLookupResponse {
        @SerializedName("ad_network_id")
        private String id;

        @SerializedName("company_ids")
        private List<String> companyIds;

        @SerializedName("lookup_failed")
        private boolean lookupFailed;

        @SerializedName("not_found")
        private boolean notFound;

        @SerializedName("is_npa")
        private boolean isNPA;
    }

    /**
     * Describes a consent update server response.
     */
    @VisibleForTesting
    protected static class ServerResponse {
        List<AdProvider> companies;

        @SerializedName("ad_network_ids")
        List<AdNetworkLookupResponse> adNetworkLookupResponses;

        @SerializedName("is_request_in_eea_or_unknown")
        Boolean isRequestLocationInEeaOrUnknown;
    }

    private static class ConsentInfoUpdateTask extends
        AsyncTask<Void, Void, ConsentInfoUpdateResponse> {

        private static final String UPDATE_SUCCESS = "Consent update successful.";

        private final String url;
        private final ConsentInformation consentInformation;
        private final List<String> publisherIds;
        private final ConsentInfoUpdateListener listener;

        ConsentInfoUpdateTask(
            String url,
            ConsentInformation consentInformation,
            List<String> publisherIds,
            ConsentInfoUpdateListener listener) {
            this.url = url;
            this.listener = listener;
            this.publisherIds = publisherIds;
            this.consentInformation = consentInformation;
        }

        private String readStream(InputStream inputStream) {
            byte[] contents = new byte[1024];
            int bytesRead;
            StringBuilder strFileContents = new StringBuilder();

            InputStream stream = new BufferedInputStream(inputStream);
            try {
                while ((bytesRead = stream.read(contents)) != -1) {
                    strFileContents.append(new String(contents, 0, bytesRead));
                }
            } catch (IOException e) {
                Log.e(TAG, e.getLocalizedMessage());
                return null;
            } finally {
                try {
                    stream.close();
                } catch (IOException e) {
                    Log.e(TAG, e.getLocalizedMessage());
                }
            }

            return strFileContents.toString();
        }

        private ConsentInfoUpdateResponse makeConsentLookupRequest(String urlString) {
            try {
                URL url = new URL(urlString);

                HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
                if (urlConnection.getResponseCode() == HttpURLConnection.HTTP_OK) {
                    String responseString = readStream(urlConnection.getInputStream());
                    urlConnection.disconnect();
                    consentInformation.updateConsentData(responseString, publisherIds);
                    return new ConsentInfoUpdateResponse(true, UPDATE_SUCCESS);
                } else {
                    return new ConsentInfoUpdateResponse(
                        false, urlConnection.getResponseMessage());
                }
            } catch (Exception e) {
                return new ConsentInfoUpdateResponse(false, e.getLocalizedMessage());
            }
        }

        @Override
        public ConsentInfoUpdateResponse doInBackground(Void... unused) {
            String publisherIdsString = TextUtils.join(",", this.publisherIds);
            ConsentData consentData = consentInformation.loadConsentData();
            Uri.Builder uriBuilder =
                Uri.parse(url)
                    .buildUpon()
                    .appendQueryParameter("pubs", publisherIdsString)
                    .appendQueryParameter("es", "2")
                    .appendQueryParameter("plat", consentData.getSDKPlatformString())
                    .appendQueryParameter("v", consentData.getSDKVersionString());
            if (consentInformation.isTestDevice()
                && consentInformation.getDebugGeography()
                   != DebugGeography.DEBUG_GEOGRAPHY_DISABLED) {
                uriBuilder =
                    uriBuilder.appendQueryParameter(
                        "debug_geo",
                        consentInformation.getDebugGeography().getCode().toString());
            }
            return makeConsentLookupRequest(uriBuilder.build().toString());
        }

        @Override
        protected void onPostExecute(ConsentInfoUpdateResponse result) {
            if (result.success) {
                this.listener.onConsentInfoUpdated(consentInformation.getConsentStatus());
            } else {
                this.listener.onFailedToUpdateConsentInfo(result.responseInfo);
            }
        }
    }

    public synchronized void setTagForUnderAgeOfConsent(boolean underAgeOfConsent) {
        ConsentData consentData = this.loadConsentData();
        consentData.tagForUnderAgeOfConsent(underAgeOfConsent);
        saveConsentData(consentData);
    }

    public synchronized boolean isTaggedForUnderAgeOfConsent() {
        return this.loadConsentData().isTaggedForUnderAgeOfConsent();
    }

    public synchronized void reset() {
        SharedPreferences.Editor editor = context.getSharedPreferences(
            PREFERENCES_FILE_KEY, Context.MODE_PRIVATE).edit();
        editor.clear();
        editor.apply();
        this.testDevices = new ArrayList<String>();
    }

    @SuppressWarnings("FutureReturnValueIgnored")
    public void requestConsentInfoUpdate(String[] publisherIds,
                                         ConsentInfoUpdateListener listener) {
        requestConsentInfoUpdate(publisherIds, MOBILE_ADS_SERVER_URL, listener);
    }

    @VisibleForTesting
    @SuppressWarnings("FutureReturnValueIgnored")
    protected void requestConsentInfoUpdate(String[] publisherIds, String url,
                                            ConsentInfoUpdateListener listener) {
        if (isTestDevice()) {
            Log.i(TAG, "This request is sent from a test device.");
        } else {
            Log.i(TAG, "Use ConsentInformation.getInstance(context).addTestDevice(\""
                      + getHashedDeviceId()
                      + "\") to get test ads on this device.");
        }
        new ConsentInfoUpdateTask(url, this, Arrays.asList(publisherIds), listener)
              .execute();
    }

    private void validatePublisherIds(final ServerResponse response) throws Exception {

        if (response.isRequestLocationInEeaOrUnknown == null) {
            throw new Exception("Could not parse Event FE preflight response.");
        }

        if (response.companies == null && response.isRequestLocationInEeaOrUnknown) {
            throw new Exception("Could not parse Event FE preflight response.");
        }

        if (!response.isRequestLocationInEeaOrUnknown) {
            return;
        }

        HashSet<String> lookupFailedPublisherIds = new HashSet<>();
        HashSet<String> notFoundPublisherIds = new HashSet<>();

        for (AdNetworkLookupResponse adNetworkLookupResponse : response.adNetworkLookupResponses) {
            if (adNetworkLookupResponse.lookupFailed) {
                lookupFailedPublisherIds.add(adNetworkLookupResponse.id);
            }

            if (adNetworkLookupResponse.notFound) {
                notFoundPublisherIds.add(adNetworkLookupResponse.id);
            }
        }

        if (lookupFailedPublisherIds.isEmpty() && notFoundPublisherIds.isEmpty()) {
            return;
        }

        StringBuilder errorString = new StringBuilder("Response error.");

        if (!lookupFailedPublisherIds.isEmpty()) {
            String lookupFailedPublisherIdsString = TextUtils.join(",", lookupFailedPublisherIds);
            errorString.append(
                String.format(" Lookup failure for: %s.", lookupFailedPublisherIdsString));
        }

        if (!notFoundPublisherIds.isEmpty()) {
            String notFoundPublisherIdsString = TextUtils.join(",", notFoundPublisherIds);
            errorString.append(
                String.format(" Publisher Ids not found: %s", notFoundPublisherIdsString));
        }

        throw new Exception(errorString.toString());
    }

    private HashSet<AdProvider> getNonPersonalizedAdProviders(List<AdProvider> adProviders,
                                          HashSet<String> nonPersonalizedAdProviderIds) {
        List<AdProvider> nonPersonalizedAdProviders = new ArrayList<>();
        for (AdProvider adProvider : adProviders) {
            if (nonPersonalizedAdProviderIds.contains(adProvider.getId())) {
                nonPersonalizedAdProviders.add(adProvider);
            }
        }

        return new HashSet<>(nonPersonalizedAdProviders);
    }

    private synchronized void updateConsentData(String responseString,
                                                List<String> publisherIds) throws Exception {
        ServerResponse response = new Gson().fromJson(responseString,
            ServerResponse.class);

        validatePublisherIds(response);

        boolean hasNonPersonalizedPublisherId = false;
        HashSet<String> nonPersonalizedAdProvidersIds = new HashSet<String>();

        if (response.adNetworkLookupResponses != null) {
            for (AdNetworkLookupResponse adNetworkLookupResponse :
                response.adNetworkLookupResponses) {
                if (!adNetworkLookupResponse.isNPA) {
                    continue;
                }

                hasNonPersonalizedPublisherId = true;
                List<String> companyIds = adNetworkLookupResponse.companyIds;
                if (companyIds != null) {
                    nonPersonalizedAdProvidersIds.addAll(companyIds);
                }
            }
        }

        HashSet<AdProvider> newAdProviderSet;
        if (response.companies == null) {
            newAdProviderSet = new HashSet<>();
        } else if (hasNonPersonalizedPublisherId) {
            newAdProviderSet =
                getNonPersonalizedAdProviders(response.companies, nonPersonalizedAdProvidersIds);
        } else {
            newAdProviderSet = new HashSet<>(response.companies);
        }

        ConsentData consentData = this.loadConsentData();

        boolean hasNonPersonalizedPublisherIdChanged =
            consentData.hasNonPersonalizedPublisherId() != hasNonPersonalizedPublisherId;

        consentData.setHasNonPersonalizedPublisherId(hasNonPersonalizedPublisherId);
        consentData.setRawResponse(responseString);
        consentData.setPublisherIds(new HashSet<>(publisherIds));
        consentData.setAdProviders(newAdProviderSet);
        consentData.setRequestLocationInEeaOrUnknown(response.isRequestLocationInEeaOrUnknown);

        if (!response.isRequestLocationInEeaOrUnknown) {
            saveConsentData(consentData);
            return;
        }

        if (!consentData.getConsentedAdProviders().containsAll(consentData.getAdProviders())
            || hasNonPersonalizedPublisherIdChanged) {
            consentData.setConsentSource("sdk");
            consentData.setConsentStatus(ConsentStatus.UNKNOWN);
            consentData.setConsentedAdProviders(new HashSet<AdProvider>());
        }
        saveConsentData(consentData);
    }

    public synchronized List<AdProvider> getAdProviders() {
        ConsentData consentData = this.loadConsentData();
        return new ArrayList<>(consentData.getAdProviders());
    }

    protected ConsentData loadConsentData() {
        SharedPreferences sharedPref = context.getSharedPreferences(PREFERENCES_FILE_KEY,
            Context.MODE_PRIVATE);
        String consentDataString = sharedPref.getString(CONSENT_DATA_KEY, "");

        if (TextUtils.isEmpty(consentDataString)) {
            return new ConsentData();
        } else {
            return new Gson().fromJson(consentDataString, ConsentData.class);
        }
    }

    private void saveConsentData(ConsentData consentData) {
        SharedPreferences sharedPref = context.getSharedPreferences(PREFERENCES_FILE_KEY,
            Context.MODE_PRIVATE);
        SharedPreferences.Editor editor = sharedPref.edit();
        String consentDataString = new Gson().toJson(consentData);
        editor.putString(CONSENT_DATA_KEY, consentDataString);
        editor.apply();
    }

    public boolean isRequestLocationInEeaOrUnknown() {
        ConsentData consentData = this.loadConsentData();
        return consentData.isRequestLocationInEeaOrUnknown();
    }

    public void setConsentStatus(ConsentStatus consentStatus) {
        this.setConsentStatus(consentStatus, "programmatic");
    }

    protected synchronized void setConsentStatus(ConsentStatus consentStatus, String source) {
        ConsentData consentData = this.loadConsentData();
        if (consentStatus == ConsentStatus.UNKNOWN) {
            consentData.setConsentedAdProviders(new HashSet<AdProvider>());
        } else {
            consentData.setConsentedAdProviders(consentData.getAdProviders());
        }

        consentData.setConsentSource(source);
        consentData.setConsentStatus(consentStatus);
        this.saveConsentData(consentData);
    }

    public synchronized ConsentStatus getConsentStatus() {
        ConsentData consentData = loadConsentData();
        return consentData.getConsentStatus();
    }
}