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

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

import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
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.ElementAnnotation;
import org.etsi.mts.tdl.execution.java.tri.Mapping;
import org.etsi.mts.tdl.execution.java.tri.NamedElement;
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.Type;
import org.etsi.mts.tdl.execution.java.tri.Validator;
import org.etsi.mts.tdl.execution.java.tri.Value;
import org.openapitools.jackson.nullable.JsonNullableModule;

import com.fasterxml.jackson.annotation.JsonInclude;
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 {

	protected Validator validator;
	protected 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) {
		t.printStackTrace();
		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 = getRequestData(message);

		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 query: {
				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(),
						query, uri.getFragment());
			}
		} catch (URISyntaxException e) {
			handleError(e);
		}

		try {
			requestBuilder.uri(uri);

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

			byte[] body = encodeBody(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 (IOException e) {
			handleError(e);
		}
	}

	@Override
	public Data receive(Data expected, Connection connection) throws InterruptedException, AssertionError {
		HttpResponseData expectedHttpData = expected != null ? getResponseData(expected) : 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 = decodeBody(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 (IOException e) {
				handleError(e);
			}
		}

		return null;
	}

	protected byte[] encodeBody(Object body) throws IOException {
		reporter.comment("Outgoing (body): " + mapper.writeValueAsString(body));
		return mapper.writeValueAsBytes(body);
	}

	protected Object decodeBody(String body, Class<?> type) throws IOException {
		if (body.length() == 0)
			return null;
		return this.mapper.readValue(body, type);
	}

	@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.");
	}

	@Override
	public Data callFunction(NamedElement function, Argument[] arguments) {
		throw new UnsupportedOperationException("Custom function implementations should be provided by extensions.");
	}

	protected HttpRequestData getRequestData(Data message) {
		if (message.getValue() instanceof HttpRequestData)
			return (HttpRequestData) message.getValue();
		if (message.getType() instanceof Type) {
			HttpRequestData req = (HttpRequestData) getMappedObject(message);
			return req;
		}
		throw new RuntimeException("Request data in unsupported format");
	}

	private Object getMappedObject(Data<Type, Value> data) {
		Type t = (Type) data.getType();
		Value v = data.getValue();

		if (!t.isStructure() && !t.isCollection() && v.getValue() != null)
			return v.getValue();

		Mapping m = t.getMapping();
		if (m == null || !m.getMappingName().equalsIgnoreCase("Java"))
			return data;

		Mapping rs = m.getResource();
		boolean isMapped = false;
		for (ElementAnnotation a : rs.getAnnotations()) {
			if (a.getKey().equals("Tdl::MappingName") && a.getValue().equals("Java")) {
				isMapped = true;
				break;
			}
		}

		if (!isMapped)
			return null;

		String cName = rs.getUri() + "." + m.getUri();
		Object obj = null;
		try {
			Class<?> cl = getClass().getClassLoader().loadClass(cName);
			if (cl.isEnum()) {
				String literal = null;
				if (v.getValue() != null)
					literal = v.getValue().toString();
				else if (v.getMapping() != null)
					literal = v.getMapping().getUri();
				obj = Enum.valueOf((Class<Enum>) cl, literal);
			} else
				obj = cl.getDeclaredConstructor().newInstance();

			if (t.isStructure()) {
				for (String pName : t.getParameters()) {
					Data<Type, Value> pData = data.getValue().getParameter(pName);
					if (pData != null) {
						Object pObj = getMappedObject(pData);
						Field pField = cl.getDeclaredField(m.getParameterMapping(pName).getUri());
						pField.set(obj, pObj);
					}
				}
			}
//			TODO isCollection()
		} catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
				| InvocationTargetException | NoSuchMethodException | SecurityException | NoSuchFieldException e) {
			handleError(e);
		}
		return obj;
	}

	protected HttpResponseData getResponseData(Data message) {
		if (message.getValue() instanceof HttpResponseData)
			return (HttpResponseData) message.getValue();
//		XXX
		return null;
	}
}