package cz.moneta.test.harness.connectors.messaging;

import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManagerFactory;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import com.ibm.mq.MQException;
import com.ibm.mq.MQGetMessageOptions;
import com.ibm.mq.MQMessage;
import com.ibm.mq.MQPutMessageOptions;
import com.ibm.mq.MQQueue;
import com.ibm.mq.MQQueueManager;
import com.ibm.mq.constants.CMQC;
import com.ibm.msg.client.wmq.WMQConstants;

import cz.moneta.test.harness.connectors.Connector;
import cz.moneta.test.harness.support.messaging.ImqRequest;
import cz.moneta.test.harness.support.messaging.MessageContentType;
import cz.moneta.test.harness.support.messaging.MqMessageFormat;
import cz.moneta.test.harness.support.messaging.ReceivedMessage;
import cz.moneta.test.harness.support.messaging.exception.MessagingConnectionException;
import cz.moneta.test.harness.support.messaging.exception.MessagingDestinationException;
import cz.moneta.test.harness.support.messaging.exception.MessagingTimeoutException;

/**
 * IBM MQ connector using the native com.ibm.mq classes. Supports
 * multi-instance Queue Manager connection lists, SSL/TLS, message properties,
 * and JSON/XML/UTF-8/EBCDIC payload formats.
 */
public class IbmMqConnector implements Connector {

	private static final Logger LOG = LogManager.getLogger(IbmMqConnector.class);

	private static final Charset EBCDIC_870 = Charset.forName("IBM870");
	private static final Charset UTF_8 = StandardCharsets.UTF_8;

	private static final long DEFAULT_POLL_INTERVAL_MS = 100;
	private static final long DEFAULT_MAX_POLL_INTERVAL_MS = 1000;

	private static final String TLS_VERSION = "TLSv1.2";
	private static final String JMS_ID_PREFIX = "ID:";
	private static final Pattern SELECTOR_EQUALS_PATTERN = Pattern
			.compile("\\s*([A-Za-z_][A-Za-z0-9_.-]*)\\s*=\\s*'((?:''|[^'])*)'\\s*");

	private final String connectionNameList;
	private final String channel;
	private final String queueManager;
	private final String user;
	private final String password;
	private final SSLSocketFactory sslSocketFactory;
	private final String sslCipherSuite;

	private MQQueueManager mqQueueManager;

	/**
	 * Constructor with multi-instance Queue Manager support.
	 *
	 * @param connectionNameList Connection name list in format
	 *                           "host1(port1),host2(port2)"
	 * @param channel            MQ channel name
	 * @param queueManager       Queue Manager name
	 * @param user               Username for authentication
	 * @param password           Password for authentication
	 * @param keystorePath       Path to SSL keystore (can be null for non-SSL)
	 * @param keystorePassword   Password for SSL keystore
	 * @param sslCipherSuite     SSL cipher suite to use (e.g.,
	 *                           "TLS_RSA_WITH_AES_256_CBC_SHA256")
	 */
	public IbmMqConnector(String connectionNameList, String channel, String queueManager, String user, String password,
			String keystorePath, String keystorePassword, String sslCipherSuite) {
		this.connectionNameList = connectionNameList;
		this.channel = channel;
		this.queueManager = queueManager;
		this.user = user;
		this.password = password;
		this.sslCipherSuite = sslCipherSuite;

		try {
			this.sslSocketFactory = keystorePath != null && !keystorePath.isBlank() && keystorePassword != null
					&& !keystorePassword.isBlank() ? getSslSocketFactory(keystorePath, keystorePassword) : null;
			connect();
		} catch (Exception e) {
			throw new MessagingConnectionException("Failed to create IBM MQ connection to " + queueManager, e);
		}
	}

	/**
	 * Connect to IBM MQ.
	 */
	private void connect() {
		try {
			this.mqQueueManager = new MQQueueManager(queueManager, createConnectionProperties());
			LOG.info("Connected to IBM MQ: {}", queueManager);
		} catch (MQException e) {
			throw new MessagingConnectionException(
					"Failed to connect to IBM MQ: " + queueManager + " - " + e.getMessage(), e);
		}
	}

	private Hashtable<String, Object> createConnectionProperties() {
		Hashtable<String, Object> properties = new Hashtable<>();
		if (connectionNameList != null && !connectionNameList.isBlank()) {
			properties.put(WMQConstants.WMQ_CONNECTION_NAME_LIST, connectionNameList);
		}
		properties.put(CMQC.CHANNEL_PROPERTY, channel);
		properties.put(CMQC.TRANSPORT_PROPERTY, CMQC.TRANSPORT_MQSERIES_CLIENT);

		if (user != null && !user.isBlank()) {
			properties.put(CMQC.USER_ID_PROPERTY, user);
		}
		if (password != null && !password.isBlank()) {
			properties.put(CMQC.PASSWORD_PROPERTY, password);
		}
		if (sslSocketFactory != null) {
			properties.put(CMQC.SSL_SOCKET_FACTORY_PROPERTY, sslSocketFactory);
		}
		if (sslCipherSuite != null && !sslCipherSuite.isBlank()) {
			properties.put(CMQC.SSL_CIPHER_SUITE_PROPERTY, sslCipherSuite);
		}

		return properties;
	}

	/**
	 * Send a JSON or XML message as MQ string message.
	 */
	private void sendTextMessage(String queueName, String payload, Map<String, String> properties) {
		sendMessage(queueName, payload.getBytes(UTF_8), UTF_8, 1208, CMQC.MQFMT_STRING, properties, "JSON/XML");
	}

	/**
	 * Send a message as raw bytes with specific encoding and CCSID.
	 */
	private void sendBytesMessage(String queueName, String payload, Charset charset, int ccsid,
			Map<String, String> properties) {
		sendMessage(queueName, payload.getBytes(charset), charset, ccsid, CMQC.MQFMT_NONE, properties,
				charset.displayName());
	}

	private void sendMessage(String queueName, byte[] payload, Charset charset, int ccsid, String mqFormat,
			Map<String, String> properties, String logFormat) {
		MQQueue queue = null;
		try {
			int openOptions = CMQC.MQOO_OUTPUT | CMQC.MQOO_FAIL_IF_QUIESCING;

			queue = openQueue(queueName, openOptions);

			MQMessage message = new MQMessage();
			message.format = mqFormat;
			message.characterSet = ccsid;
			message.write(payload);
			if (!CMQC.MQFMT_STRING.equals(mqFormat)) {
				message.setIntProperty("CCSID", ccsid);
			}

			MQPutMessageOptions putOptions = new MQPutMessageOptions();
			putOptions.options = CMQC.MQPMO_NO_SYNCPOINT | CMQC.MQPMO_FAIL_IF_QUIESCING;
			applyMessageProperties(message, properties);

			queue.put(message, putOptions);
		} catch (MQException | IOException e) {
			throw new MessagingDestinationException("Failed to send message to queue: " + queueName, e);
		} finally {
			closeQueue(queue, queueName);
		}

		LOG.debug("Sent {} message ({}) to queue: {}", logFormat, charset, queueName);
	}

	/**
	 * Send a message to a queue with specified format.
	 *
	 * @param queueName  Queue name
	 * @param payload    Message payload
	 * @param format     Message format (JSON, XML, EBCDIC_870, UTF8_1208)
	 * @param properties message properties to set
	 */
	public void send(String queueName, String payload, MqMessageFormat format, Map<String, String> properties) {
		switch (format) {
		case JSON, XML -> sendTextMessage(queueName, payload, properties);
		case EBCDIC_870 -> sendBytesMessage(queueName, payload, EBCDIC_870, 870, properties);
		case UTF8_1208 -> sendBytesMessage(queueName, payload, UTF_8, 1208, properties);
		}
	}

	/**
	 * Receive a message from a queue with timeout.
	 *
	 * @param queueName       Queue name
	 * @param messageSelector JMS-style message selector (optional). Equality
	 *                        predicates joined by AND are evaluated client-side.
	 * @param format          Expected message format
	 * @param timeout         Timeout duration
	 * @return Received message
	 */
	public ReceivedMessage receive(String queueName, String messageSelector, MqMessageFormat format, Duration timeout) {
		if (messageSelector == null || messageSelector.isBlank()) {
			return receiveNext(queueName, format, timeout);
		}

		return receiveMatching(queueName, messageSelector, format, timeout);
	}

	private ReceivedMessage receiveNext(String queueName, MqMessageFormat format, Duration timeout) {
		MQQueue queue = null;
		try {
			queue = openQueue(queueName, CMQC.MQOO_INPUT_SHARED | CMQC.MQOO_FAIL_IF_QUIESCING);
			MQMessage message = new MQMessage();
			MQGetMessageOptions getOptions = new MQGetMessageOptions();
			getOptions.options = CMQC.MQGMO_WAIT | CMQC.MQGMO_NO_SYNCPOINT | CMQC.MQGMO_FAIL_IF_QUIESCING;
			getOptions.waitInterval = toWaitInterval(timeout.toMillis());

			queue.get(message, getOptions);
			return decodeMessage(message, queueName, format);
		} catch (MQException e) {
			if (e.reasonCode == CMQC.MQRC_NO_MSG_AVAILABLE) {
				throw new MessagingTimeoutException("No message matching filter found on queue '" + queueName
						+ "' within " + timeout.toMillis() + "ms");
			}
			throw new MessagingDestinationException("Failed to receive message from queue: " + queueName, e);
		} finally {
			closeQueue(queue, queueName);
		}
	}

	private ReceivedMessage receiveMatching(String queueName, String messageSelector, MqMessageFormat format,
			Duration timeout) {
		long deadline = System.currentTimeMillis() + timeout.toMillis();
		long pollInterval = DEFAULT_POLL_INTERVAL_MS;
		Selector selector = Selector.parse(messageSelector);

		while (System.currentTimeMillis() < deadline) {
			ReceivedMessage received = tryReceiveMatching(queueName, format, selector);
			if (received != null) {
				return received;
			}

			sleepQuietly(Math.min(pollInterval, Math.max(1, deadline - System.currentTimeMillis())));
			pollInterval = Math.min(pollInterval * 2, DEFAULT_MAX_POLL_INTERVAL_MS);
		}

		throw new MessagingTimeoutException("No message matching filter found on queue '" + queueName + "' within "
				+ timeout.toMillis() + "ms");
	}

	private ReceivedMessage tryReceiveMatching(String queueName, MqMessageFormat format, Selector selector) {
		MQQueue queue = null;
		try {
			queue = openQueue(queueName,
					CMQC.MQOO_BROWSE | CMQC.MQOO_INPUT_SHARED | CMQC.MQOO_FAIL_IF_QUIESCING);

			MQGetMessageOptions browseOptions = new MQGetMessageOptions();
			browseOptions.options = CMQC.MQGMO_BROWSE_FIRST | CMQC.MQGMO_NO_SYNCPOINT | CMQC.MQGMO_FAIL_IF_QUIESCING;

			while (true) {
				MQMessage browsedMessage = new MQMessage();
				try {
					queue.get(browsedMessage, browseOptions);
				} catch (MQException e) {
					if (e.reasonCode == CMQC.MQRC_NO_MSG_AVAILABLE) {
						return null;
					}
					throw e;
				}

				ReceivedMessage browsed = decodeMessage(browsedMessage, queueName, format);
				if (selector.matches(browsed.getHeaders())) {
					MQMessage removedMessage = new MQMessage();
					MQGetMessageOptions removeOptions = new MQGetMessageOptions();
					removeOptions.options = CMQC.MQGMO_MSG_UNDER_CURSOR | CMQC.MQGMO_NO_SYNCPOINT
							| CMQC.MQGMO_FAIL_IF_QUIESCING;
					queue.get(removedMessage, removeOptions);
					return decodeMessage(removedMessage, queueName, format);
				}

				browseOptions.options = CMQC.MQGMO_BROWSE_NEXT | CMQC.MQGMO_NO_SYNCPOINT
						| CMQC.MQGMO_FAIL_IF_QUIESCING;
			}
		} catch (MQException | RuntimeException e) {
			throw new MessagingDestinationException("Failed to receive message from queue: " + queueName, e);
		} finally {
			closeQueue(queue, queueName);
		}
	}

	/**
	 * Browse a queue (non-destructive read).
	 *
	 * @param queueName       Queue name
	 * @param messageSelector JMS-style message selector (optional). Equality
	 *                        predicates joined by AND are evaluated client-side.
	 * @param format          Expected message format
	 * @param maxMessages     Maximum number of messages to browse
	 * @return List of received messages
	 */
	public List<ReceivedMessage> browse(String queueName, String messageSelector, MqMessageFormat format,
			int maxMessages) {
		List<ReceivedMessage> messages = new ArrayList<>();
		Selector selector = Selector.parse(messageSelector);
		MQQueue queue = null;

		try {
			queue = openQueue(queueName, CMQC.MQOO_BROWSE | CMQC.MQOO_FAIL_IF_QUIESCING);

			MQGetMessageOptions browseOptions = new MQGetMessageOptions();
			browseOptions.options = CMQC.MQGMO_BROWSE_FIRST | CMQC.MQGMO_NO_SYNCPOINT | CMQC.MQGMO_FAIL_IF_QUIESCING;

			while (messages.size() < maxMessages) {
				MQMessage message = new MQMessage();
				try {
					queue.get(message, browseOptions);
				} catch (MQException e) {
					if (e.reasonCode == CMQC.MQRC_NO_MSG_AVAILABLE) {
						break;
					}
					throw e;
				}

				ReceivedMessage received = decodeMessage(message, queueName, format);
				if (selector.matches(received.getHeaders())) {
					messages.add(received);
				}

				browseOptions.options = CMQC.MQGMO_BROWSE_NEXT | CMQC.MQGMO_NO_SYNCPOINT
						| CMQC.MQGMO_FAIL_IF_QUIESCING;
			}

			return messages;
		} catch (MQException e) {
			throw new MessagingDestinationException("Failed to browse queue: " + queueName, e);
		} finally {
			closeQueue(queue, queueName);
		}
	}

	/**
	 * Decode an IBM MQ message to ReceivedMessage.
	 */
	private ReceivedMessage decodeMessage(MQMessage mqMessage, String queueName, MqMessageFormat format) {
		long timestamp = mqMessage.putDateTime != null ? mqMessage.putDateTime.getTimeInMillis()
				: System.currentTimeMillis();
		if (timestamp == 0) {
			timestamp = System.currentTimeMillis();
		}

		Map<String, String> headers = new HashMap<>();
		extractMqHeadersAndProperties(mqMessage, headers, queueName);

		byte[] data = readMessageBody(mqMessage);
		String body;
		MessageContentType contentType;

		switch (format) {
		case XML -> {
			body = new String(data, charsetFor(mqMessage.characterSet, UTF_8));
			contentType = MessageContentType.XML;
		}
		case JSON -> {
			body = new String(data, charsetFor(mqMessage.characterSet, UTF_8));
			contentType = MessageContentType.JSON;
		}
		case EBCDIC_870 -> {
			body = new String(data, EBCDIC_870);
			contentType = MessageContentType.RAW_TEXT;
		}
		case UTF8_1208 -> {
			body = new String(data, UTF_8);
			contentType = MessageContentType.RAW_TEXT;
		}
		default -> {
			body = new String(data, UTF_8);
			contentType = MessageContentType.RAW_TEXT;
		}
		}

		return new ReceivedMessage(body, contentType, headers, timestamp, queueName, null);
	}

	private byte[] readMessageBody(MQMessage message) {
		try {
			message.seek(0);
			int length = message.getMessageLength();
			byte[] data = new byte[length];
			message.readFully(data);
			return data;
		} catch (EOFException e) {
			throw new RuntimeException("Failed to seek message body", e);
		} catch (IOException e) {
			throw new RuntimeException("Failed to read message body", e);
		}
	}

	/**
	 * Extract MQ headers and message properties as headers.
	 */
	private void extractMqHeadersAndProperties(MQMessage message, Map<String, String> headers, String queueName) {
		headers.put("JMSMessageID", toJmsId(message.messageId));
		headers.put("JMSCorrelationID", toJmsId(message.correlationId));
		headers.put("JMSType", getStringProperty(message, ImqRequest.PROP_JMS_TYPE, ""));
		headers.put("JMSDestination", queueName);
		headers.put("JMSDeliveryMode", String.valueOf(message.persistence));
		headers.put("JMSPriority", String.valueOf(message.priority));
		headers.put("JMSTimestamp",
				message.putDateTime != null ? String.valueOf(message.putDateTime.getTimeInMillis()) : "");
		headers.put("MQFormat", message.format != null ? message.format.trim() : "");
		headers.put("MQCharacterSet", String.valueOf(message.characterSet));
		headers.put("MQEncoding", String.valueOf(message.encoding));
		headers.put("MQBackoutCount", String.valueOf(message.backoutCount));
		headers.put("MQReplyToQueue", message.replyToQueueName != null ? message.replyToQueueName.trim() : "");
		headers.put("MQReplyToQueueManager",
				message.replyToQueueManagerName != null ? message.replyToQueueManagerName.trim() : "");
		headers.put("MQUserId", message.userId != null ? message.userId.trim() : "");

		Enumeration<String> propertyNames;
		try {
			propertyNames = message.getPropertyNames("%");
		} catch (MQException e) {
			LOG.warn("Failed to extract MQ properties", e);
			return;
		}

		while (propertyNames.hasMoreElements()) {
			String propName = propertyNames.nextElement();
			try {
				Object propValue = message.getObjectProperty(propName);
				if (propValue != null) {
					headers.put(propName, propValue.toString());
				}
			} catch (MQException e) {
				LOG.warn("Failed to extract MQ property: {}", propName, e);
			}
		}
	}

	private void applyMessageProperties(MQMessage message, Map<String, String> properties) {
		if (properties == null) {
			return;
		}

		for (Map.Entry<String, String> entry : properties.entrySet()) {
			String key = entry.getKey();
			String value = entry.getValue();
			try {
				if (ImqRequest.PROP_JMS_CORRELATION_ID.equals(key)) {
					message.correlationId = toMqId(value);
					message.setStringProperty(key, value);
				} else if (ImqRequest.PROP_JMS_MESSAGE_ID.equals(key)) {
					message.messageId = toMqId(value);
				} else if (ImqRequest.PROP_JMS_TYPE.equals(key)) {
					message.setStringProperty(ImqRequest.PROP_JMS_TYPE, value);
				} else {
					message.setStringProperty(key, value);
				}
			} catch (MQException e) {
				LOG.warn("Failed to set property: {}", key, e);
			}
		}
	}

	private String getStringProperty(MQMessage message, String property, String defaultValue) {
		try {
			String value = message.getStringProperty(property);
			return value != null ? value : defaultValue;
		} catch (MQException e) {
			return defaultValue;
		}
	}

	private MQQueue openQueue(String queueName, int openOptions) throws MQException {
		ensureConnected();
		return mqQueueManager.accessQueue(queueName, openOptions);
	}

	private void ensureConnected() {
		if (mqQueueManager == null || !mqQueueManager.isConnected()) {
			connect();
		}
	}

	private void closeQueue(MQQueue queue, String queueName) {
		if (queue == null) {
			return;
		}
		try {
			queue.close();
		} catch (MQException e) {
			LOG.warn("Failed to close IBM MQ queue: {}", queueName, e);
		}
	}

	private int toWaitInterval(long timeoutMs) {
		if (timeoutMs <= 0) {
			return 0;
		}
		return timeoutMs > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) timeoutMs;
	}

	private Charset charsetFor(int ccsid, Charset defaultCharset) {
		return switch (ccsid) {
		case 870 -> EBCDIC_870;
		case 1208 -> UTF_8;
		default -> defaultCharset;
		};
	}

	private byte[] toMqId(String value) {
		if (value == null || value.isBlank()) {
			return CMQC.MQMI_NONE.clone();
		}

		if (value.regionMatches(true, 0, JMS_ID_PREFIX, 0, JMS_ID_PREFIX.length())) {
			String hex = value.substring(JMS_ID_PREFIX.length());
			if (hex.length() == CMQC.MQ_MSG_ID_LENGTH * 2 && hex.matches("[0-9A-Fa-f]+")) {
				return hexToBytes(hex);
			}
		}

		byte[] id = CMQC.MQMI_NONE.clone();
		byte[] source = value.getBytes(UTF_8);
		System.arraycopy(source, 0, id, 0, Math.min(source.length, id.length));
		return id;
	}

	private String toJmsId(byte[] value) {
		if (value == null || value.length == 0 || Arrays.equals(value, CMQC.MQMI_NONE)) {
			return "";
		}

		StringBuilder builder = new StringBuilder(JMS_ID_PREFIX);
		for (byte b : value) {
			builder.append(String.format(Locale.ROOT, "%02X", b));
		}
		return builder.toString();
	}

	private byte[] hexToBytes(String hex) {
		byte[] data = new byte[hex.length() / 2];
		for (int i = 0; i < data.length; i++) {
			int index = i * 2;
			data[i] = (byte) Integer.parseInt(hex.substring(index, index + 2), 16);
		}
		return data;
	}

	private void sleepQuietly(long millis) {
		try {
			Thread.sleep(millis);
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
			throw new MessagingDestinationException("Interrupted while waiting for IBM MQ message", e);
		}
	}

	@Override
	public void close() {
		if (mqQueueManager != null && mqQueueManager.isConnected()) {
			try {
				mqQueueManager.disconnect();
				LOG.info("Closed connection to IBM MQ: {}", queueManager);
			} catch (MQException e) {
				LOG.error("Failed to close IBM MQ connection", e);
			}
		}
	}

	private SSLSocketFactory getSslSocketFactory(String keystorePath, String keystorePassword) throws Exception {
		// --- keystore ---
		KeyStore keyStore = KeyStore.getInstance("JKS");
		InputStream ksStream = IbmMqConnector.class.getClassLoader().getResourceAsStream(keystorePath);

		if (ksStream == null) {
			throw new IllegalStateException("Keystore not found: " + keystorePath);
		}
		keyStore.load(ksStream, keystorePassword.toCharArray());

		KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
		kmf.init(keyStore, keystorePassword.toCharArray());

		// --- truststore ---
		KeyStore trustStore = KeyStore.getInstance("JKS");
		InputStream tsStream = IbmMqConnector.class.getClassLoader().getResourceAsStream(keystorePath);

		if (tsStream == null) {
			throw new IllegalStateException("Truststore not found: " + keystorePath);
		}
		trustStore.load(tsStream, keystorePassword.toCharArray());

		TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
		tmf.init(trustStore);

		// --- SSL context ---
		SSLContext sslContext = SSLContext.getInstance(TLS_VERSION);
		sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);

		return sslContext.getSocketFactory();
	}

	private record Selector(Map<String, String> expectedValues) {

		private static Selector parse(String selector) {
			if (selector == null || selector.isBlank()) {
				return new Selector(Map.of());
			}

			Map<String, String> predicates = new HashMap<>();
			for (String predicate : selector.split("(?i)\\s+AND\\s+")) {
				Matcher matcher = SELECTOR_EQUALS_PATTERN.matcher(predicate);
				if (!matcher.matches()) {
					throw new IllegalArgumentException(
							"Unsupported IBM MQ selector expression. Supported format: property = 'value' joined by AND. Selector: "
									+ selector);
				}
				predicates.put(matcher.group(1), matcher.group(2).replace("''", "'"));
			}
			return new Selector(predicates);
		}

		private boolean matches(Map<String, String> headers) {
			for (Map.Entry<String, String> expectedValue : expectedValues.entrySet()) {
				if (!expectedValue.getValue().equals(headers.get(expectedValue.getKey()))) {
					return false;
				}
			}
			return true;
		}
	}
}
