/* 
 * Copyright 2015 by AVM GmbH <info@avm.de>
 *
 * This software contains free software; you can redistribute it and/or modify 
 * it under the terms of the GNU General Public License ("License") as 
 * published by the Free Software Foundation  (version 3 of the License). 
 * This software is distributed in the hope that it will be useful, but 
 * WITHOUT ANY WARRANTY; without even the implied warranty of 
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the copy of the 
 * License you received along with this software for more details.
 */

package de.avm.android.tr064.soap;

import android.text.TextUtils;
import de.avm.android.tr064.Tr064Log;
import de.avm.android.tr064.exceptions.BaseException;
import de.avm.android.tr064.exceptions.DataMisformatException;
import de.avm.android.tr064.exceptions.SoapException;
import de.avm.android.tr064.exceptions.SslErrorException;
import de.avm.android.tr064.net.GzipHttpRequestInterceptor;
import de.avm.android.tr064.net.GzipHttpResponseInterceptor;
import de.avm.android.tr064.net.SoapSSLClientFactory;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpResponseException;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;

import javax.net.ssl.SSLException;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public abstract class AbstractSoapHelper<RESULT>
{
	private static final String TAG = "AbstractSoapHelper";
	
	protected final ISoapCredentials mSoapCredentials;
    private boolean mIsMultipleRequestsOptimization = false;
    private DefaultHttpClient mHttpClient = null;

	/**
	 * Instantiates a new soap helper.
	 */
	public AbstractSoapHelper(ISoapCredentials soapCredentials)
	{
		// never do TR-064-Remote without SSL!
		if (soapCredentials.isRemote() && soapCredentials.isSuppressSSL())
			throw new IllegalArgumentException(
					"Argument soapCredentials: Remote connections without SSL not allowed!");
		mSoapCredentials = soapCredentials;
	}

    /**
     * Reuse HTTP client in this instance with multiple requests. When done, call
     * {@link #closeIdleConnections()}
     * @param enable
     */
    public void enableMultipleRequestsOptimization(boolean enable)
    {
        mIsMultipleRequestsOptimization = enable;
        if (!enable) closeIdleConnections();
    }

    /**
     * Close idle connection reused with multiple requests.
     * (See {@link #enableMultipleRequestsOptimization(boolean)})
     */
    public void closeIdleConnections()
    {
        if (mHttpClient != null)
        {
            mHttpClient.getConnectionManager().closeIdleConnections(1, TimeUnit.MILLISECONDS);
            mHttpClient = null;
        }
    }

	public abstract String getSoapMethod();

	public abstract RESULT getQualifiedResult()
			throws IOException, BaseException;

	public abstract String getNamespace();

	public abstract String getControlURL();

	public String getSoapMethodParameter()
	{
		return "";
	}
	
	protected String getInvalidResponseErrorMessage()
	{
		return "Invalid Response from " + getNamespace(); 
	}

	/**
	 * Fetches the soap body using SSL 
	 * 
	 * @return the soap body
	 * @throws IOException thrown only after
	 * 		setHandleConnectionProblem(false) has been called
	 * @throws BaseException
	 */
    protected String getSoapBody()
            throws IOException, BaseException
    {
        return getSoapBody(true);
    }

	/**
	 * Fetches the soap body 
	 * 
	 * @param useSsl use SSL or not
	 * @return the soap body
	 * @throws IOException thrown only after
	 * 		setHandleConnectionProblem(false) has been called
	 * @throws BaseException
	 */
	protected String getSoapBody(boolean useSsl)
			throws IOException, BaseException
	{
		// no SSL for testing?
		if (useSsl && mSoapCredentials.isSuppressSSL()) useSsl = false;
		
		String ret = "";
		try
		{
			String host = mSoapCredentials.getHost();
			int port = mSoapCredentials.getPort(useSsl);
			if ((port < 1) || TextUtils.isEmpty(host))
				throw new UnknownHostException();

            // reuse HTTP client on multiple requests
            DefaultHttpClient client = (mIsMultipleRequestsOptimization) ? mHttpClient : null;
            if (client == null)
            {
                client = SoapSSLClientFactory.getClientWithDigestAuth(port,
                        mSoapCredentials.getUsername(), mSoapCredentials.getPassword(),
                        mSoapCredentials.getPinningStore());
                client.addRequestInterceptor(new GzipHttpRequestInterceptor());
                client.addResponseInterceptor(new GzipHttpResponseInterceptor());
                if (mIsMultipleRequestsOptimization) mHttpClient = client;
            }

			String soapEndpoint = ((useSsl) ? "https://" : "http://") +
					host + ":" + Integer.toString(port) +
					((mSoapCredentials.isRemote()) ? "/tr064" : "") + getControlURL();
			String requestBody = createRequestBody();
			String soapAction = getNamespace() + "#" + getSoapMethod();
			Tr064Log.d("SOAP-Endpoint", soapEndpoint);
			Tr064Log.d("SOAP-Action", soapAction);
			Tr064Log.d("RequestBody", filterSoapBeforeTrace(requestBody));

			HttpPost post = new HttpPost(soapEndpoint);
			post.setEntity(new StringEntity(requestBody, "utf-8"));
			post.addHeader("User-Agent", "AVM FRITZ!App");
			post.addHeader("SOAPACTION", soapAction);
			post.addHeader("Content-Type", "text/xml; charset=\"utf-8\"");
			post.addHeader("Accept", "text/xml");
            if (!mIsMultipleRequestsOptimization)
			    post.addHeader("Connection", "close");

			HttpResponse resp = client.execute(post);
			if (resp.getStatusLine().getStatusCode() ==
					HttpStatus.SC_INTERNAL_SERVER_ERROR)
			{
				SoapException exp = SoapException.create(resp.getEntity());
				if (exp != null)
				{
					Tr064Log.d("REPLY", resp.getStatusLine().toString() +
                            ", SOAP error " + exp.getSoapError() +
                            ": " + exp.getMessage());
					throw exp;
				}
			}
			if (resp.getStatusLine().getStatusCode() >= 300)
			{
				Tr064Log.d("REPLY", resp.getStatusLine().toString());
				throw new HttpResponseException(
						resp.getStatusLine().getStatusCode(),
						resp.getStatusLine().getReasonPhrase());
			}	
			InputStream input = resp.getEntity().getContent();
			BufferedReader reader = new BufferedReader(new InputStreamReader(
					input));
			String str;
			while ((str = reader.readLine()) != null) {
				ret += str;
				Tr064Log.d("REPLY", filterSoapBeforeTrace(str));
			}
            if (TextUtils.isEmpty(ret))
            {
                Tr064Log.d("REPLY", resp.getStatusLine().toString());
                Tr064Log.d("REPLY", "HTTP response without SOAP body!");
                throw new BaseException("Response without SOAP body from FRITZ!Box");
            }
		}
		catch (ClientProtocolException e)
		{
			throw new BaseException("Invalid Response from FRITZ!Box", e);
		}
		catch (SSLException e)
		{
			throw new SslErrorException(e);
		}
		catch (IOException e)
		{
			if (SslErrorException.isSslError(e))
				throw new SslErrorException(e);
			else
				throw e;
		}
		return ret;
	}

	/**
	 * Create the soap request body.
	 * 
	 * @return the string
	 */
	private String createRequestBody()
	{
        return "<?xml version=\"1.0\"?><s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" "
				+ "s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
				+ "<s:Body>"
				+ "<u:"
				+ getSoapMethod()
				+ " xmlns:u=\""
				+ getNamespace()
				+ "\">"
				+ getSoapMethodParameter()
				+ "</u:"
				+ getSoapMethod() + ">" + "</s:Body></s:Envelope>";
	}
	
	protected static String decodeEntities(String str)
	{
        return str.replace("&lt;", "<")
        		.replace("&apos;", "'")
        		.replace("&quot;", "\"")
        		.replace("&gt;", ">")
        		.replace("&amp;", "&");
	}

    protected static String encodeEntities(String str)
    {
        return str.replace("<", "&lt;")
                .replace("'", "&apos;")
                .replace("\"", "&quot;")
                .replace(">", "&gt;")
                .replace("&", "&amp;");
    }
	
	/**
	 * Apply a workaround to download URLs if needed
	 * 
	 * @param url the URL
	 * @return the may-be-fixed-URL
	 */
	protected String fixDownloadUrl(String url)
	{
		try
		{
			// should it be applied?
			if (mSoapCredentials.shouldFixDownloadUrl())
			{
				String host = mSoapCredentials.getHost();
				if (!TextUtils.isEmpty(host) && !TextUtils.isEmpty(url))
				{
					URI uri = new URI(url);
					if (!host.equalsIgnoreCase(uri.getHost()))
					{
						String result =  new URI(uri.getScheme(),
								uri.getUserInfo(), host, uri.getPort(),
								uri.getPath(), uri.getQuery(),
								uri.getFragment()).toString();
						Tr064Log.d(TAG, "fixed download url: " + result);
						return result;
					}
				}
			}
		}
		catch(Exception e)
		{
			Tr064Log.e(TAG, "Failed to fix download URL. Might be wrong!", e);
		}
		
		return url;
	}
	
	/**
	 * Filter SOAP body before dumping it it to the trace,
	 * remove/replace sensitive data here
	 * 
	 * @param body
	 * 		body to be sent
	 * @return
	 * 		trace output created from body
	 */
	protected String filterSoapBeforeTrace(String body)
	{
		// don't filter by default
		return body;
	}

    /**
     * Replaces content of a tag with "SECRET". Only first occurrence of every tag is processed.
     * @param body
     *      XML body fragment
     * @param tags
     *      tags to be processed
     * @return
     *      XML fragment after replacing the tag's content
     */
    protected String replaceSecrets(String body, String[] tags)
    {
        for (String tag : tags)
        {
            StringBuilder builder = new StringBuilder();
            int pos = body.indexOf("<" + tag + ">");
            if (pos > -1)
            {
                builder.append(body.substring(0, pos + tag.length() + 2));
                builder.append("SECRET");
                pos = body.indexOf("</" + tag + ">", pos + tag.length() + 2);
                if (pos > -1) builder.append(body.substring(pos));
                body = builder.toString();
            }
        }
        return body;
    }

	/**
	 * Helper method. Get a string value for a given xml-tagname
	 * 
	 * @param name
	 * 				the name of the tag
	 * @param input
	 * 				the input-xml-string
	 * @return
	 * 				the string value by name
	 * @throws DataMisformatException
	 * 				tag not found
	 */
	protected String getValueByName(String name, String input)
			throws DataMisformatException
	{
		if (input.length() == 0)
			return "";

		Matcher m = Pattern.compile("<" + name + ">(.*?)<\\/" + name + ">")
				.matcher(input);
		if (m.find())
			return decodeEntities(m.group(1));
		else
			throw new DataMisformatException(getInvalidResponseErrorMessage());
	}
	
	/**
	 * Helper method. Get an int value for a given xml-tagname
	 * 
	 * @param name
	 * 				the name of the tag
	 * @param input
	 * 				the input-xml-string
	 * @return
	 * 				the int value by name
	 * @throws DataMisformatException
	 * 				tag not found or invalid content
	 */
	protected int getIntByName(String name, String input)
			throws DataMisformatException
	{
		try
		{
			return Integer.parseInt(getValueByName(name, input));
		}
		catch (NumberFormatException exp)
		{
			throw new DataMisformatException(getInvalidResponseErrorMessage(), exp);
		}
	}
	
	/**
	 * Helper method. Get a long value for a given xml-tagname
	 * 
	 * @param name
	 * 				the name of the tag
	 * @param input
	 * 				the input-xml-string
	 * @return
	 * 				the long value by name
	 * @throws DataMisformatException
	 * 				tag not found or invalid content
	 */
	protected long getLongByName(String name, String input)
			throws DataMisformatException
	{
		try
		{
			return Long.parseLong(getValueByName(name, input));
		}
		catch (NumberFormatException exp)
		{
			throw new DataMisformatException(getInvalidResponseErrorMessage(), exp);
		}
	}
	
	/**
	 * Helper method. Get a boolean value for a given xml-tagname
	 * 
	 * @param name
	 * 				the name of the tag
	 * @param input
	 * 				the input-xml-string
	 * @return
	 * 				the boolean value by name
	 * @throws DataMisformatException
	 * 				tag not found or invalid content
	 */
	protected boolean getBooleanByName(String name, String input)
			throws DataMisformatException
	{
		try
		{
			switch (Integer.parseInt(getValueByName(name, input)))
			{
				case 0:
					return false;
					
				case 1:
					return true;
					
				default:
					throw new DataMisformatException(getInvalidResponseErrorMessage());
			}
		}
		catch (NumberFormatException exp)
		{
			throw new DataMisformatException(getInvalidResponseErrorMessage(), exp);
		}
	}

	protected void exceptOnFault(String input)
	{
		Matcher m = Pattern.compile("<faultcode>(.*?)<\\/faultcode>")
				.matcher(input);
		if (m.find())
			throw new DataMisformatException(getInvalidResponseErrorMessage());
	}
}