/*
 * Copyright (C) 2014 The Android Open Source Project
 *
 * 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.android.mms.service;

import android.content.Context;
import android.net.ConnectivityManager;
import android.net.LinkProperties;
import android.net.Network;
import android.os.Bundle;
import android.telephony.CarrierConfigManager;
import android.telephony.SmsManager;
import android.telephony.SubscriptionManager;
import android.telephony.TelephonyManager;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;

import com.android.internal.annotations.VisibleForTesting;
import com.android.mms.service.exception.MmsHttpException;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.ProtocolException;
import java.net.Proxy;
import java.net.URL;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * MMS HTTP client for sending and downloading MMS messages
 */
public class MmsHttpClient {
    public static final String METHOD_POST = "POST";
    public static final String METHOD_GET = "GET";

    private static final String HEADER_CONTENT_TYPE = "Content-Type";
    private static final String HEADER_ACCEPT = "Accept";
    private static final String HEADER_ACCEPT_LANGUAGE = "Accept-Language";
    private static final String HEADER_USER_AGENT = "User-Agent";
    private static final String HEADER_CONNECTION = "Connection";

    // The "Accept" header value
    private static final String HEADER_VALUE_ACCEPT =
            "*/*, application/vnd.wap.mms-message, application/vnd.wap.sic";
    // The "Content-Type" header value
    private static final String HEADER_VALUE_CONTENT_TYPE_WITH_CHARSET =
            "application/vnd.wap.mms-message; charset=utf-8";
    private static final String HEADER_VALUE_CONTENT_TYPE_WITHOUT_CHARSET =
            "application/vnd.wap.mms-message";
    private static final String HEADER_CONNECTION_CLOSE = "close";

    // Used for configs that specify a UA_PROF_URL, but not a name
    private static final String UA_PROF_TAG_NAME_DEFAULT = "x-wap-profile";

    private static final int IPV4_WAIT_ATTEMPTS = 15;
    private static final long IPV4_WAIT_DELAY_MS = 1000; // 1 seconds

    private final Context mContext;
    private final Network mNetwork;
    private final ConnectivityManager mConnectivityManager;

    /**
     * Constructor
     *
     * @param context The Context object
     * @param network The Network for creating an OKHttp client
     */
    public MmsHttpClient(Context context, Network network,
            ConnectivityManager connectivityManager) {
        mContext = context;
        // Mms server is on a carrier private network so it may not be resolvable using 3rd party
        // private dns
        mNetwork = network.getPrivateDnsBypassingCopy();
        mConnectivityManager = connectivityManager;
    }

    /**
     * Execute an MMS HTTP request, either a POST (sending) or a GET (downloading)
     *
     * @param urlString  The request URL, for sending it is usually the MMSC, and for downloading
     *                   it is the message URL
     * @param pdu        For POST (sending) only, the PDU to send
     * @param method     HTTP method, POST for sending and GET for downloading
     * @param isProxySet Is there a proxy for the MMSC
     * @param proxyHost  The proxy host
     * @param proxyPort  The proxy port
     * @param mmsConfig  The MMS config to use
     * @param subId      The subscription ID used to get line number, etc.
     * @param requestId  The request ID for logging
     * @return The HTTP response body
     * @throws MmsHttpException For any failures
     */
    public byte[] execute(String urlString, byte[] pdu, String method, boolean isProxySet,
            String proxyHost, int proxyPort, Bundle mmsConfig, int subId, String requestId)
            throws MmsHttpException {
        LogUtil.d(requestId, "HTTP: " + method + " " + redactUrlForNonVerbose(urlString)
                + (isProxySet ? (", proxy=" + proxyHost + ":" + proxyPort) : "")
                + ", PDU size=" + (pdu != null ? pdu.length : 0));
        checkMethod(method);
        HttpURLConnection connection = null;
        try {
            Proxy proxy = Proxy.NO_PROXY;
            if (isProxySet) {
                proxy = new Proxy(Proxy.Type.HTTP,
                        new InetSocketAddress(mNetwork.getByName(proxyHost), proxyPort));
            }
            final URL url = new URL(urlString);
            maybeWaitForIpv4(requestId, url);
            // Now get the connection
            connection = (HttpURLConnection) mNetwork.openConnection(url, proxy);
            connection.setDoInput(true);
            connection.setConnectTimeout(
                    mmsConfig.getInt(SmsManager.MMS_CONFIG_HTTP_SOCKET_TIMEOUT));
            connection.setReadTimeout(
                    mmsConfig.getInt(SmsManager.MMS_CONFIG_HTTP_SOCKET_TIMEOUT));
            // ------- COMMON HEADERS ---------
            // Header: Accept
            connection.setRequestProperty(HEADER_ACCEPT, HEADER_VALUE_ACCEPT);
            // Header: Accept-Language
            connection.setRequestProperty(
                    HEADER_ACCEPT_LANGUAGE, getCurrentAcceptLanguage(Locale.getDefault()));
            // Header: User-Agent
            final String userAgent = mmsConfig.getString(SmsManager.MMS_CONFIG_USER_AGENT);
            LogUtil.i(requestId, "HTTP: User-Agent=" + userAgent);
            connection.setRequestProperty(HEADER_USER_AGENT, userAgent);
            // Header: x-wap-profile
            String uaProfUrlTagName =
                    mmsConfig.getString(SmsManager.MMS_CONFIG_UA_PROF_TAG_NAME);
            final String uaProfUrl = mmsConfig.getString(SmsManager.MMS_CONFIG_UA_PROF_URL);

            if (!TextUtils.isEmpty(uaProfUrl)) {
                if (TextUtils.isEmpty(uaProfUrlTagName)) {
                    uaProfUrlTagName = UA_PROF_TAG_NAME_DEFAULT;
                }

                LogUtil.i(requestId,
                        "HTTP: UaProfUrl=" + uaProfUrl + ", UaProfUrlTagName=" + uaProfUrlTagName);

                connection.setRequestProperty(uaProfUrlTagName, uaProfUrl);
            }
            // Header: Connection: close (if needed)
            // Some carriers require that the HTTP connection's socket is closed
            // after an MMS request/response is complete. In these cases keep alive
            // is disabled. See https://tools.ietf.org/html/rfc7230#section-6.6
            if (mmsConfig.getBoolean(CarrierConfigManager.KEY_MMS_CLOSE_CONNECTION_BOOL, false)) {
                LogUtil.i(requestId, "HTTP: Connection close after request");
                connection.setRequestProperty(HEADER_CONNECTION, HEADER_CONNECTION_CLOSE);
            }
            // Add extra headers specified by mms_config.xml's httpparams
            addExtraHeaders(connection, mmsConfig, subId);
            // Different stuff for GET and POST
            if (METHOD_POST.equals(method)) {
                if (pdu == null || pdu.length < 1) {
                    LogUtil.e(requestId, "HTTP: empty pdu");
                    throw new MmsHttpException(0/*statusCode*/, "Sending empty PDU");
                }
                connection.setDoOutput(true);
                connection.setRequestMethod(METHOD_POST);
                if (mmsConfig.getBoolean(SmsManager.MMS_CONFIG_SUPPORT_HTTP_CHARSET_HEADER)) {
                    connection.setRequestProperty(HEADER_CONTENT_TYPE,
                            HEADER_VALUE_CONTENT_TYPE_WITH_CHARSET);
                } else {
                    connection.setRequestProperty(HEADER_CONTENT_TYPE,
                            HEADER_VALUE_CONTENT_TYPE_WITHOUT_CHARSET);
                }
                if (LogUtil.isLoggable(Log.VERBOSE)) {
                    logHttpHeaders(connection.getRequestProperties(), requestId);
                }
                connection.setFixedLengthStreamingMode(pdu.length);
                // Sending request body
                final OutputStream out =
                        new BufferedOutputStream(connection.getOutputStream());
                out.write(pdu);
                out.flush();
                out.close();
            } else if (METHOD_GET.equals(method)) {
                if (LogUtil.isLoggable(Log.VERBOSE)) {
                    logHttpHeaders(connection.getRequestProperties(), requestId);
                }
                connection.setRequestMethod(METHOD_GET);
            }
            // Get response
            final int responseCode = connection.getResponseCode();
            final String responseMessage = connection.getResponseMessage();
            LogUtil.d(requestId, "HTTP: " + responseCode + " " + responseMessage);
            if (LogUtil.isLoggable(Log.VERBOSE)) {
                logHttpHeaders(connection.getHeaderFields(), requestId);
            }
            if (responseCode / 100 != 2) {
                throw new MmsHttpException(responseCode, responseMessage);
            }
            final InputStream in = new BufferedInputStream(connection.getInputStream());
            final ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
            final byte[] buf = new byte[4096];
            int count = 0;
            while ((count = in.read(buf)) > 0) {
                byteOut.write(buf, 0, count);
            }
            in.close();
            final byte[] responseBody = byteOut.toByteArray();
            LogUtil.d(requestId, "HTTP: response size="
                    + (responseBody != null ? responseBody.length : 0));
            return responseBody;
        } catch (MalformedURLException e) {
            final String redactedUrl = redactUrlForNonVerbose(urlString);
            LogUtil.e(requestId, "HTTP: invalid URL " + redactedUrl, e);
            throw new MmsHttpException(0/*statusCode*/, "Invalid URL " + redactedUrl, e);
        } catch (ProtocolException e) {
            final String redactedUrl = redactUrlForNonVerbose(urlString);
            LogUtil.e(requestId, "HTTP: invalid URL protocol " + redactedUrl, e);
            throw new MmsHttpException(0/*statusCode*/, "Invalid URL protocol " + redactedUrl, e);
        } catch (IOException e) {
            LogUtil.e(requestId, "HTTP: IO failure", e);
            throw new MmsHttpException(0/*statusCode*/, e);
        } finally {
            if (connection != null) {
                connection.disconnect();
            }
        }
    }

    private void maybeWaitForIpv4(final String requestId, final URL url) {
        // If it's a literal IPv4 address and we're on an IPv6-only network,
        // wait until IPv4 is available.
        Inet4Address ipv4Literal = null;
        try {
            ipv4Literal = (Inet4Address) InetAddress.parseNumericAddress(url.getHost());
        } catch (IllegalArgumentException | ClassCastException e) {
            // Ignore
        }
        if (ipv4Literal == null) {
            // Not an IPv4 address.
            return;
        }
        for (int i = 0; i < IPV4_WAIT_ATTEMPTS; i++) {
            final LinkProperties lp = mConnectivityManager.getLinkProperties(mNetwork);
            if (lp != null) {
                if (!lp.isReachable(ipv4Literal)) {
                    LogUtil.w(requestId, "HTTP: IPv4 not yet provisioned");
                    try {
                        Thread.sleep(IPV4_WAIT_DELAY_MS);
                    } catch (InterruptedException e) {
                        // Ignore
                    }
                } else {
                    LogUtil.i(requestId, "HTTP: IPv4 provisioned");
                    break;
                }
            } else {
                LogUtil.w(requestId, "HTTP: network disconnected, skip ipv4 check");
                break;
            }
        }
    }

    private static void logHttpHeaders(Map<String, List<String>> headers, String requestId) {
        final StringBuilder sb = new StringBuilder();
        if (headers != null) {
            for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
                final String key = entry.getKey();
                final List<String> values = entry.getValue();
                if (values != null) {
                    for (String value : values) {
                        sb.append(key).append('=').append(value).append('\n');
                    }
                }
            }
            LogUtil.v(requestId, "HTTP: headers\n" + sb.toString());
        }
    }

    private static void checkMethod(String method) throws MmsHttpException {
        if (!METHOD_GET.equals(method) && !METHOD_POST.equals(method)) {
            throw new MmsHttpException(0/*statusCode*/, "Invalid method " + method);
        }
    }

    private static final String ACCEPT_LANG_FOR_US_LOCALE = "en-US";

    /**
     * Return the Accept-Language header.  Use the current locale plus
     * US if we are in a different locale than US.
     * This code copied from the browser's WebSettings.java
     *
     * @return Current AcceptLanguage String.
     */
    public static String getCurrentAcceptLanguage(Locale locale) {
        final StringBuilder buffer = new StringBuilder();
        addLocaleToHttpAcceptLanguage(buffer, locale);

        if (!Locale.US.equals(locale)) {
            if (buffer.length() > 0) {
                buffer.append(", ");
            }
            buffer.append(ACCEPT_LANG_FOR_US_LOCALE);
        }

        return buffer.toString();
    }

    /**
     * Convert obsolete language codes, including Hebrew/Indonesian/Yiddish,
     * to new standard.
     */
    private static String convertObsoleteLanguageCodeToNew(String langCode) {
        if (langCode == null) {
            return null;
        }
        if ("iw".equals(langCode)) {
            // Hebrew
            return "he";
        } else if ("in".equals(langCode)) {
            // Indonesian
            return "id";
        } else if ("ji".equals(langCode)) {
            // Yiddish
            return "yi";
        }
        return langCode;
    }

    private static void addLocaleToHttpAcceptLanguage(StringBuilder builder, Locale locale) {
        final String language = convertObsoleteLanguageCodeToNew(locale.getLanguage());
        if (language != null) {
            builder.append(language);
            final String country = locale.getCountry();
            if (country != null) {
                builder.append("-");
                builder.append(country);
            }
        }
    }

    /**
     * Add extra HTTP headers from mms_config.xml's httpParams, which is a list of key/value
     * pairs separated by "|". Each key/value pair is separated by ":". Value may contain
     * macros like "##LINE1##" or "##NAI##" which is resolved with methods in this class
     *
     * @param connection The HttpURLConnection that we add headers to
     * @param mmsConfig  The MmsConfig object
     * @param subId      The subscription ID used to get line number, etc.
     */
    private void addExtraHeaders(HttpURLConnection connection, Bundle mmsConfig, int subId) {
        final String extraHttpParams = mmsConfig.getString(SmsManager.MMS_CONFIG_HTTP_PARAMS);
        if (!TextUtils.isEmpty(extraHttpParams)) {
            // Parse the parameter list
            String paramList[] = extraHttpParams.split("\\|");
            for (String paramPair : paramList) {
                String splitPair[] = paramPair.split(":", 2);
                if (splitPair.length == 2) {
                    final String name = splitPair[0].trim();
                    final String value =
                            resolveMacro(mContext, splitPair[1].trim(), mmsConfig, subId);
                    if (!TextUtils.isEmpty(name) && !TextUtils.isEmpty(value)) {
                        // Add the header if the param is valid
                        connection.setRequestProperty(name, value);
                    }
                }
            }
        }
    }

    private static final Pattern MACRO_P = Pattern.compile("##(\\S+)##");

    /**
     * Resolve the macro in HTTP param value text
     * For example, "something##LINE1##something" is resolved to "something9139531419something"
     *
     * @param value The HTTP param value possibly containing macros
     * @param subId The subscription ID used to get line number, etc.
     * @return The HTTP param with macros resolved to real value
     */
    private static String resolveMacro(Context context, String value, Bundle mmsConfig, int subId) {
        if (TextUtils.isEmpty(value)) {
            return value;
        }
        final Matcher matcher = MACRO_P.matcher(value);
        int nextStart = 0;
        StringBuilder replaced = null;
        while (matcher.find()) {
            if (replaced == null) {
                replaced = new StringBuilder();
            }
            final int matchedStart = matcher.start();
            if (matchedStart > nextStart) {
                replaced.append(value.substring(nextStart, matchedStart));
            }
            final String macro = matcher.group(1);
            final String macroValue = getMacroValue(context, macro, mmsConfig, subId);
            if (macroValue != null) {
                replaced.append(macroValue);
            }
            nextStart = matcher.end();
        }
        if (replaced != null && nextStart < value.length()) {
            replaced.append(value.substring(nextStart));
        }
        return replaced == null ? value : replaced.toString();
    }

    /**
     * Redact the URL for non-VERBOSE logging. Replace url with only the host part and the length
     * of the input URL string.
     */
    public static String redactUrlForNonVerbose(String urlString) {
        if (LogUtil.isLoggable(Log.VERBOSE)) {
            // Don't redact for VERBOSE level logging
            return urlString;
        }
        if (TextUtils.isEmpty(urlString)) {
            return urlString;
        }
        String protocol = "http";
        String host = "";
        try {
            final URL url = new URL(urlString);
            protocol = url.getProtocol();
            host = url.getHost();
        } catch (MalformedURLException e) {
            // Ignore
        }
        // Print "http://host[length]"
        final StringBuilder sb = new StringBuilder();
        sb.append(protocol).append("://").append(host)
                .append("[").append(urlString.length()).append("]");
        return sb.toString();
    }

    private static String getPhoneNumberForMacroLine1(TelephonyManager telephonyManager,
        Context context, int subId) {
        String phoneNo = telephonyManager.getLine1Number();
        if (TextUtils.isEmpty(phoneNo)) {
            SubscriptionManager subscriptionManager = context.getSystemService(
                SubscriptionManager.class);
            if (subscriptionManager != null) {
                phoneNo = subscriptionManager.getPhoneNumber(subId);
            } else {
                LogUtil.e("subscriptionManager is null");
            }
        }
        return phoneNo;
    }

    /*
     * Macro names
     */
    // The raw phone number from TelephonyManager.getLine1Number
    private static final String MACRO_LINE1 = "LINE1";
    // The phone number without country code
    private static final String MACRO_LINE1NOCOUNTRYCODE = "LINE1NOCOUNTRYCODE";
    // NAI (Network Access Identifier), used by Sprint for authentication
    private static final String MACRO_NAI = "NAI";

    /**
     * Return the HTTP param macro value.
     * Example: "LINE1" returns the phone number, etc.
     *
     * @param macro     The macro name
     * @param mmsConfig The MMS config which contains NAI suffix.
     * @param subId     The subscription ID used to get line number, etc.
     * @return The value of the defined macro
     */
    @VisibleForTesting
    public static String getMacroValue(Context context, String macro, Bundle mmsConfig,
        int subId) {
        final TelephonyManager telephonyManager = ((TelephonyManager) context.getSystemService(
            Context.TELEPHONY_SERVICE)).createForSubscriptionId(subId);
        if (MACRO_LINE1.equals(macro)) {
            return getPhoneNumberForMacroLine1(telephonyManager, context, subId);
        } else if (MACRO_LINE1NOCOUNTRYCODE.equals(macro)) {
            return PhoneUtils.getNationalNumber(telephonyManager,
                getPhoneNumberForMacroLine1(telephonyManager, context, subId));
        } else if (MACRO_NAI.equals(macro)) {
            return getNai(telephonyManager, mmsConfig);
        }
        LogUtil.e("Invalid macro " + macro);
        return null;
    }

    /**
     * Returns the NAI (Network Access Identifier) from SystemProperties for the given subscription
     * ID.
     */
    private static String getNai(TelephonyManager telephonyManager, Bundle mmsConfig) {
        String nai = telephonyManager.getNai();
        if (LogUtil.isLoggable(Log.VERBOSE)) {
            LogUtil.v("getNai: nai=" + nai);
        }

        if (!TextUtils.isEmpty(nai)) {
            String naiSuffix = mmsConfig.getString(SmsManager.MMS_CONFIG_NAI_SUFFIX);
            if (!TextUtils.isEmpty(naiSuffix)) {
                nai = nai + naiSuffix;
            }
            byte[] encoded = null;
            try {
                encoded = Base64.encode(nai.getBytes("UTF-8"), Base64.NO_WRAP);
            } catch (UnsupportedEncodingException e) {
                encoded = Base64.encode(nai.getBytes(), Base64.NO_WRAP);
            }
            try {
                nai = new String(encoded, "UTF-8");
            } catch (UnsupportedEncodingException e) {
                nai = new String(encoded);
            }
        }
        return nai;
    }
}
