package org.etsi.mts.tdl.execution.java.adapters.http;

import static java.nio.charset.StandardCharsets.UTF_8;

import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.regex.Pattern;

import org.etsi.mts.tdl.execution.java.rt.core.PojoData;
import org.etsi.mts.tdl.execution.java.tri.Argument;
import org.etsi.mts.tdl.execution.java.tri.Connection;
import org.etsi.mts.tdl.execution.java.tri.Data;
import org.etsi.mts.tdl.execution.java.tri.Procedure;
import org.etsi.mts.tdl.execution.java.tri.Reporter;
import org.etsi.mts.tdl.execution.java.tri.SystemAdapter;
import org.etsi.mts.tdl.execution.java.tri.Validator;
import org.openapitools.jackson.nullable.JsonNullableModule;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

public class HttpSystemAdapter implements SystemAdapter {

	private Validator validator;
	private Reporter reporter;

	private HttpClient.Builder builder;
	private HttpClient client;
	private ObjectMapper mapper;


	private String baseUri = "https://example.com";
	private List<HttpHeader> defaultHeaders = new ArrayList<HttpHeader>();

	private Connection[] connections;

	private Queue<HttpResponse<String>> unhandledResponses = new ConcurrentLinkedQueue<>();

	public HttpSystemAdapter(Validator validator, Reporter reporter) {
		this.validator = validator;
		this.reporter = reporter;
	}
	
	public void setBaseUri(String baseUri) {
		this.baseUri = baseUri;
	}
	
	/**
	 * Set headers that are applied to all requests, such as authentication.
	 * The default implementation adds by default:
	 * <ul>
	 * <li>Content-Type: application/json</li>
	 * <li>Accept: application/json</li>
	 * </ul>   
	 */
	public void setDefaultHeaders(List<HttpHeader> defaultHeaders) {
		this.defaultHeaders = defaultHeaders;
	}

	@Override
	public void configure(Connection[] connections) {
		if (connections.length > 1)
			System.err.println("TODO: multiple connections not supported");

		// TODO multiple connections
		this.connections = connections;

		builder = HttpClient.newBuilder();
		client = builder.build();

		mapper = new ObjectMapper();
		mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
		mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
		mapper.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false);
		mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
		mapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING);
		mapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING);
		mapper.disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE);
		/*
		 * TODO optional modules 
		 * 
		 */
		mapper.registerModule(new JavaTimeModule());
		mapper.registerModule(new JsonNullableModule());
		
	}

	private void handleResponse(HttpResponse<String> response, Throwable t) {
		if (t != null)
			handleError(t);
		else {
			synchronized (unhandledResponses) {
				unhandledResponses.add(response);
				unhandledResponses.notifyAll();
			}
		}
	}

	private void handleError(Throwable t) {
		reporter.runtimeError(t);
		throw new RuntimeException(t);
	}

	private void applyDefaults(HttpRequest.Builder requestBuilder) {
		requestBuilder.header("Content-Type", "application/json");
		requestBuilder.header("Accept", "application/json");
		for (HttpHeader h: defaultHeaders) {
			requestBuilder.header(h.name, h.value);
		}
	}

	@Override
	public void send(Data message, Connection connection) {
		HttpRequestData httpData = (HttpRequestData) message.getValue();

		HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
		applyDefaults(requestBuilder);

		// Parameters
		String query = null;
		for (HttpRequestParameter p : httpData.parameters) {
			if (p.value == null)
				continue;
			switch (p.location) {
			case path: {
				httpData.uri = httpData.uri.replaceAll(Pattern.quote("{" + p.name + "}"), p.value);
				break;
			}
			case quey: {
				String queryParameter = p.name + "=" + URLEncoder.encode(p.value, UTF_8);
				if (query == null)
					query = queryParameter;
				else
					query += "&" + queryParameter;
				break;
			}
			case cookie: {
				System.err.println("TODO: cookie parameters not handled");
				break;
			}
			}
		}
		URI uri = URI.create(baseUri + httpData.uri);
		try {
			if (query != null) {
				if (uri.getQuery() != null)
					query = uri.getQuery() + "&" + query;
				uri = new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(), uri.getPath(),
						URLEncoder.encode(query, UTF_8), uri.getFragment());
			}
		} catch (URISyntaxException e) {
			handleError(e);
		}

		try {
			requestBuilder.uri(uri);

			// TODO logging
			reporter.comment("Outgoing: " + httpData.method.toString() + " | " + uri + " | " + mapper.writeValueAsString(httpData.body));

			byte[] body = mapper.writeValueAsBytes(httpData.body);
			requestBuilder.method(httpData.method.toString(), HttpRequest.BodyPublishers.ofByteArray(body));

			for (HttpHeader header : httpData.headers)
				requestBuilder.header(header.name, header.value);

			CompletableFuture<HttpResponse<String>> responseFuture = client.sendAsync(requestBuilder.build(),
					HttpResponse.BodyHandlers.ofString(UTF_8));

			responseFuture.whenCompleteAsync(this::handleResponse);

		} catch (JsonProcessingException e) {
			handleError(e);
		}
	}

	@Override
	public Data receive(Data expected, Connection connection) throws InterruptedException, AssertionError {
		HttpResponseData expectedHttpData = expected != null ? (HttpResponseData) expected.getValue() : null;

		synchronized (unhandledResponses) {
			HttpResponse<String> response = unhandledResponses.peek();
			while (response == null) {
				unhandledResponses.wait();
				response = unhandledResponses.peek();
			}

			// TODO logging
			reporter.comment("Incoming: " + response.statusCode() + " | " + response.body());

			try {

				HttpResponseData receivedHttpData = new HttpResponseData();
				receivedHttpData.status = response.statusCode();
				Map<String, List<String>> headers = response.headers().map();
				receivedHttpData.headers = new ArrayList<>();
				for (String header : headers.keySet()) {
					receivedHttpData.headers.add(new HttpHeader(header, headers.get(header)));
				}

				Data received = new PojoData<>(receivedHttpData);
				if (expected != null) {
					if (expectedHttpData.body != null) {
						receivedHttpData.body = this.mapper.readValue(response.body(),
								expectedHttpData.body.getClass());
					}
					if (this.validator.matches(expected, received)) {
						unhandledResponses.remove();
						return received;
					}

				} else {
					receivedHttpData.body = response.body();
					unhandledResponses.remove();
					return received;
				}

			} catch (JsonProcessingException e) {
				handleError(e);
			}
		}

		return null;
	}

	@Override
	public Data call(Procedure operation, Argument[] arguments, Data expectedReturn, Data expectedException,
			Connection connection) {
		throw new UnsupportedOperationException("Procedure-based communication is not supported by this adapter.");
	}

	@Override
	public Data[] receiveCall(Procedure operation, Data[] expectedArguments, Connection connection) {
		throw new UnsupportedOperationException("Procedure-based communication is not supported by this adapter.");
	}

	@Override
	public void replyCall(Procedure operation, Data returnValue, Data exception, Connection connection) {
		throw new UnsupportedOperationException("Procedure-based communication is not supported by this adapter.");
	}
}