package org.jboss.as.test.integration.web.handlers;

import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import jakarta.websocket.ContainerProvider;
import jakarta.websocket.WebSocketContainer;

import org.apache.http.Header;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.StatusLine;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicHeader;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpCoreContext;
import org.apache.http.util.EntityUtils;
import org.jboss.as.test.integration.web.websocket.AnnotatedClient;
import org.jboss.logging.Logger;
import org.junit.Assert;

/**
 * Tests the use of Undertow request dumping handler. This is base class that implements particular test behaviour and controls
 * what is tested.
 *
 * @author <a href="mailto:jstourac@redhat.com">Jan Stourac</a>
 */
public abstract class RequestDumpingHandlerTestImpl {

    private static Logger log = Logger.getLogger(RequestDumpingHandlerTestImpl.class);

    private final int TOTAL_DELAY = 3000;
    private final int SLEEP_TIMEOUT = 200;

    private final Path logFilePath;

    // Expected value of the "contentType" header in response - default is "text/plain" but can be overridden by child class
    protected String contentType = "text/plain";
    // Expected value of the "status" header in response - default is "200" but can be overridden by child class
    protected String status = "200";
    // Expected value of the "scheme" header in request - default is "http" but can be overridden by child class
    protected String scheme = "http";

    /**
     * Constructor that immediately executes test body.
     *
     * @param uri             testing URI to connect to
     * @param logFilePath     path to log file in which are logged request dumps
     * @param requestDumperOn whether request dumping feature is enabled at all
     */
    public RequestDumpingHandlerTestImpl(URI uri, Path logFilePath, boolean requestDumperOn) throws Exception {
        this.logFilePath = logFilePath;
        commonTestBody(uri, requestDumperOn);
    }

    /***
     * Abstract method which implements way of performing request to the server. It should be implemented depending on what type
     * of request we want to perform (HTTP, HTTPS, etc.).
     *
     * @param uri testing URI to connect to
     * @return 2dimensional array - request (as a first member) and response (as a second member) headers arrays; in case that
     *         there is no simple way how to obtain request and response headers then returns null
     * @throws Exception
     */
    public abstract Header[][] performRequest(URI uri) throws Exception;

    /**
     * Common test body part.
     *
     * @param uri             deployment URI
     * @param requestDumperOn whether RequestDumpingHandler is turned on
     * @throws Exception
     */
    private void commonTestBody(URI uri, boolean requestDumperOn) throws Exception {
        // Test whether custom log file exists already. If so then count number of lines in it so further we will ignore them.
        long skipBytes = 0;
        if (logFilePath.toFile().exists() && logFilePath.toFile().isFile()) {
            skipBytes = logFilePath.toFile().length();
        } else {
            log.trace("The log file ('" + logFilePath + "') does not exist yet, that is ok.");
        }

        // Perform request on server...
        Header[] reqHdrs = null;
        Header[] respHdrs = null;
        Header[][] hdrs = performRequest(uri);
        if (hdrs != null) {
            reqHdrs = hdrs[0];
            respHdrs = hdrs[1];
        }

        // Test whether there is request dump for particular URL after the HTTP request executed...
        testLogForDumpWithURL(logFilePath, uri.getPath(), skipBytes, requestDumperOn);

        if (requestDumperOn) {
            // If we expect request dump -> check its data.
            checkReqDumpData(logFilePath, skipBytes, reqHdrs, respHdrs, uri.getHost(), uri.getPort(), uri.getPath());
        }
    }

    /**
     * Reads content of the file into a string variable.
     *
     * @param logFilePath
     * @param skipBytes   number of bytes from the beginning of the file that should be skipped
     * @return content of the file as a string
     * @throws FileNotFoundException
     */
    private String readLogFile(Path logFilePath, long skipBytes) throws FileNotFoundException, IOException {
        Assert.assertTrue("Log file ('" + logFilePath + "') does not exist", logFilePath.toFile().exists());
        Assert.assertTrue("The '" + logFilePath + "' is not a file", logFilePath.toFile().isFile());

        // logfile exists -> read its content...
        LineNumberReader lnr = new LineNumberReader(Files.newBufferedReader(logFilePath, StandardCharsets.UTF_8));
        StringBuilder sb = new StringBuilder();

        log.trace("I am skipping '" + skipBytes + "' bytes from the beggining of the file.");
        lnr.skip(skipBytes);

        String input;
        while ((input = lnr.readLine()) != null) {
            sb.append("\n" + input);
        }
        lnr.close();

        return sb.toString();
    }

    /**
     * Searching log for request dump of request to particular path. If no such request dump is found there is sanity loop to
     * ensure that system has had enough time to write data to the disk.
     *
     * @param logFilePath path to log file
     * @param path        URI path searched for in log file
     * @param skipBytes   number of bytes from the beginning of the file that should be skipped
     * @param expected    whether we expect to find given path
     * @throws FileNotFoundException
     */
    private void testLogForDumpWithURL(Path logFilePath, String path, long skipBytes, boolean expected) throws Exception {
        Pattern pattern = Pattern.compile("-+REQUEST-+.+" + path + ".+-+RESPONSE-+", Pattern.DOTALL);
        Matcher m;

        long startTime = System.currentTimeMillis();
        boolean hasFound = false;
        long currTime;
        String content;

        // Give system time to write data on disk...
        do {
            currTime = System.currentTimeMillis();
            content = readLogFile(logFilePath, skipBytes);
            m = pattern.matcher(content);

            // Search for pattern...
            if (m.find()) {
                hasFound = true;
                break;
            }
            Thread.sleep(SLEEP_TIMEOUT);
        } while (currTime - startTime < TOTAL_DELAY);

        log.trace("I have read following content of the file '" + logFilePath + "':\n" + content + "\n---END-OF-FILE-OUTPUT---");

        // Finally compare search result with our expectation...
        Assert.assertEquals("Searching for pattern: '" + pattern + "' in log file ('" + logFilePath.toString() + "')",
                expected, hasFound);
    }

    /**
     * Check request dumper data.
     *
     * @param logFilePath path to log file
     * @param skipBytes   number of bytes from the beginning of the file that should be skipped
     * @param reqHdrs     request headers
     * @param respHdrs    response headers
     * @param host        server IP address
     * @param port        server listening port
     * @param path        URI path
     * @throws IOException
     */
    private void checkReqDumpData(Path logFilePath, long skipBytes, Header[] reqHdrs, Header[] respHdrs, String host, int port, String path) throws IOException {
        String content = readLogFile(logFilePath, skipBytes);

        // Split into request and response part:
        String request = content.substring(0, content.indexOf("-RESPONSE-"));
        String response = content.substring(content.indexOf("-RESPONSE-"));

        // Check request dump part...
        searchInFile(request, "-+REQUEST-+");
        searchInFile(request, "\\s+URI=" + Pattern.quote(path));
        searchInFile(request, "\\s+characterEncoding=");
        searchInFile(request, "\\s+contentLength=");
        searchInFile(request, "\\s+contentType=");

        searchForHeaders(request, reqHdrs);

        searchInFile(request, "\\s+locale=\\[.*\\]");
        searchInFile(request, "\\s+method=GET");
        searchInFile(request, "\\s+protocol=");
        searchInFile(request, "\\s+queryString=");
        searchInFile(request, "\\s+remoteAddr=");
        searchInFile(request, "\\s+remoteHost=");
        searchInFile(request, "\\s+scheme=" + Pattern.quote(scheme));
        searchInFile(request, "\\s+host=" + Pattern.quote(host));
        searchInFile(request, "\\s+serverPort=" + Pattern.quote(String.valueOf(port)));

        // Now check response dump part...
        searchInFile(response, "-+RESPONSE-+");
        searchInFile(response, "\\s+contentLength=");
        searchInFile(response, "\\s+contentType=" + Pattern.quote(contentType));

        searchForHeaders(response, respHdrs);

        searchInFile(response, "\\s+status=" + Pattern.quote(status));
    }

    /**
     * Search for request and response headers. Respect that they might be in different order.
     *
     * @param content content in which is searched
     * @param hdrs    array of headers which should be searched for; if null then no check will be performed; if zero length then
     *                no header line is expected in the log
     * @throws FileNotFoundException
     */
    private void searchForHeaders(String content, Header[] hdrs) throws FileNotFoundException {
        if (hdrs == null) {
            log.trace("No array with headers given -> skipping testing header content in log file.");
            return;
        }

        final String HEADER_REGEXP = "\\s+header=";

        if (hdrs.length > 0) {
            // Check that number of "header" occurrences equals to hdrs.length
            Assert.assertEquals("", hdrs.length, countMatch(content, HEADER_REGEXP));

            for (Header hdr : hdrs) {
                // Close current scanner, reopen it and move to start pattern directly...
                searchInFile(content, HEADER_REGEXP + Pattern.quote(hdr.getName()) + "=" + Pattern.quote(hdr.getValue()));
            }
        } else {
            // request contains no headers -> we really do not expect it to be in dump
            searchInFile(content, HEADER_REGEXP, false);
        }
    }

    /**
     * Searches in given content for given pattern.
     *
     * @param content content of the file as a string
     * @param regExp  regular expression that is searched in the file
     */
    private void searchInFile(String content, String regExp) {
        searchInFile(content, regExp, true);
    }

    /**
     * Searches in given content for given pattern.
     *
     * @param content  content of the file as a string
     * @param regExp   regular expression that is searched in the file
     * @param expected whether searching pattern is expected to be found or not
     */
    private void searchInFile(String content, String regExp, boolean expected) {
        Pattern pattern = Pattern.compile(regExp);
        Matcher m = pattern.matcher(content);

        Assert.assertEquals("Searching for pattern: '" + regExp + "' in log file ('" + logFilePath.toString() + "')", expected,
                m.find());
    }

    /**
     * Counts number of occurrences of given string in given content.
     *
     * @param content in this content will be searching for given pattern
     * @param regExp  given pattern to search in content
     * @return number of occurrences in given content
     */
    private int countMatch(String content, String regExp) {
        Pattern pattern = Pattern.compile(regExp);
        Matcher m = pattern.matcher(content);
        int occurs = 0;

        while (m.find()) {
            occurs++;
        }

        return occurs;
    }

    /**
     * Testing class which implements HTTPS requests on server.
     *
     * @author <a href="mailto:jstourac@redhat.com">Jan Stourac</a>
     */
    static class HttpsRequestDumpingHandlerTestImpl extends RequestDumpingHandlerTestImpl {

        HttpsRequestDumpingHandlerTestImpl(URI uri, Path logFilePath, boolean requestDumperOn) throws Exception {
            super(uri, logFilePath, requestDumperOn);
        }

        @Override
        public Header[][] performRequest(URI uri) throws Exception {
            // Override value of the "scheme" header expected in response
            scheme = "https";

            // Create a trust manager that does not validate certificate chains
            TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() {

                @Override
                public X509Certificate[] getAcceptedIssuers() {
                    return new X509Certificate[0];
                }

                @Override
                public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
                }

                @Override
                public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
                }
            }};

            SSLContext sc = SSLContext.getInstance("TLS");
            sc.init(null, trustAllCerts, new java.security.SecureRandom());
            HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());

            HttpsURLConnection httpsConn = (HttpsURLConnection) uri.toURL().openConnection();

            httpsConn.setDoOutput(false);

            httpsConn.setHostnameVerifier(new HostnameVerifier() {
                @Override
                public boolean verify(String hostname, SSLSession session) {
                    return true;
                }
            });

            BufferedReader br = new BufferedReader(new InputStreamReader(httpsConn.getInputStream(), StandardCharsets.UTF_8));

            StringBuilder sb = new StringBuilder();
            String input;

            while ((input = br.readLine()) != null) {
                sb = sb.append(input);
            }
            br.close();
            httpsConn.disconnect();

            Header[][] reqAndrespHeaders = new Header[2][];
            reqAndrespHeaders[1] = retrieveHeaders(httpsConn.getHeaderFields());

            log.trace("The content of the URL ('" + uri + "'):\n" + sb.toString());

            Assert.assertEquals(200, httpsConn.getResponseCode());
            Assert.assertEquals("Could not reach expected content via http request", "A file", sb.toString());

            // NOTE: leaving request headers null (won't be checked) as there is no easy way how to obtain them
            return reqAndrespHeaders;
        }

        private Header[] retrieveHeaders(Map<String, List<String>> headers) {
            //System.out.println("---- PRINTING HEADERS ----");
            LinkedList<Header> hdrsList = new LinkedList<Header>();
            for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
                for (String value : entry.getValue()) {
                    if (entry.getKey() != null) {
                        //System.out.println(header + ": " + value);
                        hdrsList.add(new BasicHeader(entry.getKey(), value));
                    }
                }
            }
            return hdrsList.toArray(new Header[hdrsList.size()]);
        }
    }

    /**
     * Testing class that implements standard HTTP requests.
     *
     * @author <a href="mailto:jstourac@redhat.com">Jan Stourac</a>
     */
    static class HttpRequestDumpingHandlerTestImpl extends RequestDumpingHandlerTestImpl {

        HttpRequestDumpingHandlerTestImpl(URI uri, Path logFilePath, boolean requestDumperOn) throws Exception {
            super(uri, logFilePath, requestDumperOn);
        }

        @Override
        public Header[][] performRequest(URI uri) throws Exception {
            Header[][] ret = new Header[2][];

            try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
                HttpGet httpget = new HttpGet(uri.toURL().toExternalForm() + "file.txt");

                HttpContext localContext = new BasicHttpContext();
                HttpResponse response = httpClient.execute(httpget, localContext);
                HttpRequest request = (HttpRequest) localContext.getAttribute(HttpCoreContext.HTTP_REQUEST);

                // Fill return values
                ret[0] = request.getAllHeaders();
                ret[1] = response.getAllHeaders();

                StatusLine statusLine = response.getStatusLine();
                Assert.assertEquals(200, statusLine.getStatusCode());

                String result = EntityUtils.toString(response.getEntity());
                Assert.assertEquals("Could not reach expected content via http request", "A file", result);
            }
            return ret;
        }
    }

    /**
     * Testing class that implements standard websocket requests via http upgrade.
     *
     * @author <a href="mailto:jstourac@redhat.com">Jan Stourac</a>
     */
    static class WsRequestDumpingHandlerTestImpl extends RequestDumpingHandlerTestImpl {

        WsRequestDumpingHandlerTestImpl(URI uri, Path logFilePath, boolean requestDumperOn) throws Exception {
            super(uri, logFilePath, requestDumperOn);
        }

        @Override
        public Header[][] performRequest(URI uri) throws Exception {
            // Override value of the "contentType" header expected in response
            contentType = "null";
            // Override value of the "status" header expected in response
            status = "101";

            AnnotatedClient endpoint = new AnnotatedClient();
            WebSocketContainer serverContainer = ContainerProvider.getWebSocketContainer();
            serverContainer.connectToServer(endpoint, uri);
            Assert.assertEquals("Hello Stuart", endpoint.getMessage());

            // NOTE: leaving request and response headers null (won't be checked) as there is no easy way how to obtain them
            return null;
        }
    }
}
