/*
 * ====================================================================
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.
 * ====================================================================
 *
 * This software consists of voluntary contributions made by many
 * individuals on behalf of the Apache Software Foundation.  For more
 * information on the Apache Software Foundation, please see
 * <http://www.apache.org/>.
 *
 */

package org.apache.hc.core5.testing.framework;

import static org.apache.hc.core5.testing.framework.ClientPOJOAdapter.BODY;
import static org.apache.hc.core5.testing.framework.ClientPOJOAdapter.CONTENT_TYPE;
import static org.apache.hc.core5.testing.framework.ClientPOJOAdapter.HEADERS;
import static org.apache.hc.core5.testing.framework.ClientPOJOAdapter.METHOD;
import static org.apache.hc.core5.testing.framework.ClientPOJOAdapter.REQUEST;
import static org.apache.hc.core5.testing.framework.ClientPOJOAdapter.RESPONSE;
import static org.apache.hc.core5.testing.framework.ClientPOJOAdapter.STATUS;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import org.apache.hc.core5.http.HttpVersion;
import org.apache.hc.core5.http.ProtocolVersion;
import org.apache.hc.core5.http.impl.bootstrap.HttpServer;
import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap;
import org.apache.hc.core5.http.impl.routing.RequestRouter;
import org.apache.hc.core5.http.io.HttpRequestHandler;
import org.apache.hc.core5.http.io.SocketConfig;
import org.apache.hc.core5.io.CloseMode;

public class TestingFramework {

    /**
     * Use the ALL_METHODS list to conveniently cycle through all HTTP methods.
     */
    public static final List<String> ALL_METHODS = Arrays.asList("HEAD", "GET", "DELETE", "POST", "PUT", "PATCH");

    /**
     * If an {@link ClassicTestClientTestingAdapter} is unable to return a response in
     * the format this testing framework is needing, then it will need to check the
     * item in the response (such as body, status, headers, or contentType) itself and set
     * the returned value of the item as ALREADY_CHECKED.
     */
    public static final Object ALREADY_CHECKED = new Object();

    /**
     * If a test does not specify a path, this one is used.
     */
    public static final String DEFAULT_REQUEST_PATH = "a/path";

    /**
     * If a test does not specify a body, this one is used.
     */
    public static final String DEFAULT_REQUEST_BODY = "{\"location\":\"home\"}";

    /**
     * If a test does not specify a request contentType, this one is used.
     */
    public static final String DEFAULT_REQUEST_CONTENT_TYPE = "application/json";

    /**
     * If a test does not specify query parameters, these are used.
     */
    public static final Map<String, String> DEFAULT_REQUEST_QUERY;

    /**
     * If a test does not specify a request headers, these are used.
     */
    public static final Map<String, String> DEFAULT_REQUEST_HEADERS;

    /**
     * If a test does not specify a protocol version, this one is used.
     */
    public static final ProtocolVersion DEFAULT_REQUEST_PROTOCOL_VERSION = HttpVersion.HTTP_1_1;

    /**
     * If a test does not specify an expected response status, this one is used.
     */
    public static final int DEFAULT_RESPONSE_STATUS = 200;

    /**
     * If a test does not specify an expected response body, this one is used.
     */
    public static final String DEFAULT_RESPONSE_BODY = "{\"location\":\"work\"}";

    /**
     * If a test does not specify an expected response contentType, this one is used.
     */
    public static final String DEFAULT_RESPONSE_CONTENT_TYPE = "application/json";

    /**
     * If a test does not specify expected response headers, these are used.
     */
    public static final Map<String, String> DEFAULT_RESPONSE_HEADERS;

    static {
        final Map<String, String> request = new HashMap<>();
        request.put("p1", "this");
        request.put("p2", "that");
        DEFAULT_REQUEST_QUERY = Collections.unmodifiableMap(request);

        Map<String, String> headers = new HashMap<>();
        headers.put("header1", "stuff");
        headers.put("header2", "more stuff");
        DEFAULT_REQUEST_HEADERS = Collections.unmodifiableMap(headers);

        headers = new HashMap<>();
        headers.put("header3", "header_three");
        headers.put("header4", "header_four");
        DEFAULT_RESPONSE_HEADERS = Collections.unmodifiableMap(headers);
    }

    private ClientTestingAdapter adapter;
    private TestingFrameworkRequestHandler requestHandler = new TestingFrameworkRequestHandler();
    private List<FrameworkTest> tests = new ArrayList<>();

    private HttpServer server;
    private int port;

    public TestingFramework() throws TestingFrameworkException {
        this(null);
    }

    public TestingFramework(final ClientTestingAdapter adapter) throws TestingFrameworkException {
        this.adapter = adapter;

        /*
         * By default, a set of tests that will exercise each HTTP method are pre-loaded.
         */
        for (final String method : ALL_METHODS) {
            final List<Integer> statusList = Arrays.asList(200, 201);
            for (final Integer status : statusList) {
                final Map<String, Object> request = new HashMap<>();
                request.put(METHOD, method);

                final Map<String, Object> response = new HashMap<>();
                response.put(STATUS, status);

                final Map<String, Object> test = new HashMap<>();
                test.put(REQUEST, request);
                test.put(RESPONSE, response);

                addTest(test);
            }
        }
    }

    /**
     * This is not likely to be used except during the testing of this class.
     * It is used to inject a mocked request handler.
     *
     * @param requestHandler
     */
    public void setRequestHandler(final TestingFrameworkRequestHandler requestHandler) {
        this.requestHandler = requestHandler;
    }

    /**
     * Run the tests that have been previously added.  First, an in-process {@link HttpServer} is
     * started.  Then, all the tests are completed by passing each test to the adapter
     * which will make the HTTP request.
     *
     * @throws TestingFrameworkException if there is a test failure or unexpected problem.
     */
    public void runTests() throws TestingFrameworkException {
        if (adapter == null) {
            throw new TestingFrameworkException("adapter should not be null");
        }

        startServer();

        try {
            for (final FrameworkTest test : tests) {
                try {
                    callAdapter(test);
                } catch (final Throwable t) {
                    processThrowable(t, test);
                }
            }
        } finally {
            stopServer();
        }
    }

    private void processThrowable(final Throwable t, final FrameworkTest test) throws TestingFrameworkException {
        final TestingFrameworkException e;
        if (t instanceof TestingFrameworkException) {
            e = (TestingFrameworkException) t;
        } else {
            e = new TestingFrameworkException(t);
        }
        e.setAdapter(adapter);
        e.setTest(test);
        throw e;
    }

    private void startServer() throws TestingFrameworkException {
        /*
         * Start an in-process server and handle all HTTP requests
         * with the requestHandler.
         */
        final SocketConfig socketConfig = SocketConfig.custom()
                                          .setSoTimeout(15000, TimeUnit.MILLISECONDS)
                                          .build();

        final ServerBootstrap serverBootstrap = ServerBootstrap.bootstrap()
                                          .setLocalAddress(InetAddress.getLoopbackAddress())
                                          .setSocketConfig(socketConfig)
                                          .setRequestRouter(RequestRouter.<HttpRequestHandler>builder()
                                                .addRoute(RequestRouter.LOCAL_AUTHORITY, "*", requestHandler)
                                                .resolveAuthority(RequestRouter.LOCAL_AUTHORITY_RESOLVER)
                                                .build());

        server = serverBootstrap.create();
        try {
            server.start();
        } catch (final IOException e) {
            throw new TestingFrameworkException(e);
        }

        port = server.getLocalPort();
    }

    private void stopServer() {
        final HttpServer local = this.server;
        this.server = null;
        if (local != null) {
            local.close(CloseMode.IMMEDIATE);
        }
    }

    private void callAdapter(final FrameworkTest test) throws TestingFrameworkException {
        Map<String, Object> request = test.initRequest();

        /*
         * If the adapter does not support the particular request, skip the test.
         */
        if (! adapter.isRequestSupported(request)) {
            return;
        }

        /*
         * Allow the adapter to modify the request before the request expectations
         * are given to the requestHandler.  Typically, adapters should not have
         * to modify the request.
         */
        request = adapter.modifyRequest(request);

        // Tell the request handler what to expect in the request.
        requestHandler.setRequestExpectations(request);

        Map<String, Object> responseExpectations = test.initResponseExpectations();
        /*
         * Allow the adapter to modify the response expectations before the handler
         * is told what to return.  Typically, adapters should not have to modify
         * the response expectations.
         */
        responseExpectations = adapter.modifyResponseExpectations(request, responseExpectations);

        // Tell the request handler what response to return.
        requestHandler.setDesiredResponse(responseExpectations);

        /*
         * Use the adapter to make the HTTP call.  Make sure the responseExpectations are not changed
         * since they have already been sent to the request handler and they will later be used
         * to check the response.
         */
        final String defaultURI = getDefaultURI();
        final Map<String, Object> response = adapter.execute(
                                                defaultURI,
                                                request,
                                                requestHandler,
                                                Collections.unmodifiableMap(responseExpectations));
        /*
         * The adapter is welcome to call assertNothingThrown() earlier, but we will
         * do it here to make sure it is done.  If the handler threw any exception
         * while checking the request it received, it will be re-thrown here.
         */
        requestHandler.assertNothingThrown();

        assertResponseMatchesExpectation(request.get(METHOD), response, responseExpectations);
    }

    @SuppressWarnings("unchecked")
    private void assertResponseMatchesExpectation(final Object method, final Map<String, Object> actualResponse,
                                                  final Map<String, Object> expectedResponse)
                                                  throws TestingFrameworkException {
        if (actualResponse == null) {
            throw new TestingFrameworkException("response should not be null");
        }
        /*
         * Now check the items in the response unless the adapter says they
         * already checked something.
         */
        if (actualResponse.get(STATUS) != TestingFramework.ALREADY_CHECKED) {
            assertStatusMatchesExpectation(actualResponse.get(STATUS), expectedResponse.get(STATUS));
        }
        if (! method.equals("HEAD")) {
            if (actualResponse.get(BODY) != TestingFramework.ALREADY_CHECKED) {
                assertBodyMatchesExpectation(actualResponse.get(BODY), expectedResponse.get(BODY));
            }
            if (actualResponse.get(CONTENT_TYPE) != TestingFramework.ALREADY_CHECKED) {
                assertContentTypeMatchesExpectation(actualResponse.get(CONTENT_TYPE), expectedResponse.get(CONTENT_TYPE));
            }
        }
        if (actualResponse.get(HEADERS) != TestingFramework.ALREADY_CHECKED) {
            assertHeadersMatchExpectation((Map<String, String>) actualResponse.get(HEADERS),
                                          (Map<String, String>) expectedResponse.get(HEADERS));
        }
    }

    private void assertStatusMatchesExpectation(final Object actualStatus, final Object expectedStatus)
            throws TestingFrameworkException {
        if (actualStatus == null) {
            throw new TestingFrameworkException("Returned status is null.");
        }
        if (expectedStatus != null && !actualStatus.equals(expectedStatus)) {
            throw new TestingFrameworkException("Expected status not found. expected="
                                                  + expectedStatus + "; actual=" + actualStatus);
        }
    }

    private void assertBodyMatchesExpectation(final Object actualBody, final Object expectedBody)
        throws TestingFrameworkException {
        if (actualBody == null) {
            throw new TestingFrameworkException("Returned body is null.");
        }
        if (expectedBody != null && !actualBody.equals(expectedBody)) {
            throw new TestingFrameworkException("Expected body not found. expected="
                                    + expectedBody + "; actual=" + actualBody);
        }
    }

    private void assertContentTypeMatchesExpectation(final Object actualContentType, final Object expectedContentType)
        throws TestingFrameworkException {
        if (expectedContentType != null) {
            if (actualContentType == null) {
                throw new TestingFrameworkException("Returned contentType is null.");
            }
            if (! actualContentType.equals(expectedContentType)) {
                throw new TestingFrameworkException("Expected content type not found.  expected="
                                    + expectedContentType + "; actual=" + actualContentType);
            }
        }
    }

    private void assertHeadersMatchExpectation(final Map<String, String> actualHeaders,
                                               final Map<String, String> expectedHeaders)
            throws TestingFrameworkException {
        if (expectedHeaders == null) {
            return;
        }
        for (final Map.Entry<String, String> expectedHeader : expectedHeaders.entrySet()) {
            final String expectedHeaderName = expectedHeader.getKey();
            if (! actualHeaders.containsKey(expectedHeaderName)) {
                throw new TestingFrameworkException("Expected header not found: name=" + expectedHeaderName);
            }
            if (! actualHeaders.get(expectedHeaderName).equals(expectedHeaders.get(expectedHeaderName))) {
                throw new TestingFrameworkException("Header value not expected: name=" + expectedHeaderName
                        + "; expected=" + expectedHeaders.get(expectedHeaderName)
                        + "; actual=" + actualHeaders.get(expectedHeaderName));
            }
        }
    }

    private String getDefaultURI() {
        return "http://localhost:" + port + "/";
    }

    /**
     * Sets the {@link ClientTestingAdapter}.
     *
     * @param adapter
     */
    public void setAdapter(final ClientTestingAdapter adapter) {
        this.adapter = adapter;
    }

    /**
     * Deletes all tests.
     */
    public void deleteTests() {
        tests = new ArrayList<>();
    }

    /**
     * Call to add a test with defaults.
     *
     * @throws TestingFrameworkException
     */
    public void addTest() throws TestingFrameworkException {
        addTest(null);
    }

    /**
     * Call to add a test.  The test is a map with a REQUEST and a RESPONSE key.
     * See {@link ClientPOJOAdapter} for details on the format of the request and response.
     *
     * @param test Map with a REQUEST and a RESPONSE key.
     * @throws TestingFrameworkException
     */
    @SuppressWarnings("unchecked")
    public void addTest(final Map<String, Object> test) throws TestingFrameworkException {
        final Map<String, Object> testCopy = (Map<String, Object>) deepcopy(test);

        tests.add(new FrameworkTest(testCopy));
    }

    /**
     * Used to make a "deep" copy of an object.  This testing framework makes deep copies
     * of tests that are added as well as requestExpectations Maps and response Maps.
     *
     * @param orig a serializable object.
     * @return a deep copy of the orig object.
     * @throws TestingFrameworkException
     */
    public static Object deepcopy(final Object orig) throws TestingFrameworkException {
        try {
            // this is from http://stackoverflow.com/questions/13155127/deep-copy-map-in-groovy
            final ByteArrayOutputStream bos = new ByteArrayOutputStream();
            final ObjectOutputStream oos = new ObjectOutputStream(bos);
            oos.writeObject(orig);
            oos.flush();
            final ByteArrayInputStream bin = new ByteArrayInputStream(bos.toByteArray());
            final ObjectInputStream ois = new ObjectInputStream(bin);
            return ois.readObject();
        } catch (final ClassNotFoundException | IOException ex) {
            throw new TestingFrameworkException(ex);
        }
    }
}
