package cz.moneta.test.harness.support.messaging.kafka;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import cz.moneta.test.harness.endpoints.kafka.KafkaEndpoint;
import cz.moneta.test.harness.support.util.FileReader;
import org.apache.commons.lang3.StringUtils;

import java.time.Duration;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Fluent builder for creating and sending Kafka messages.
 * <p>
 * Usage:
 * <pre>{@code
 * harness.withKafka()
 *     .toTopic("order-events")
 *     .withKey("order-123")
 *     .withPayload("{\"orderId\": \"123\", \"status\": \"CREATED\"}")
 *     .withTraceparent("00-traceId-spanId-01")
 *     .send();
 * }</pre>
 * </p>
 */
public class KafkaRequest {

    private static final ObjectMapper MAPPER = new ObjectMapper();
    private static final Pattern ARRAY_NODE_PATTERN = Pattern.compile("(.*?)\\[([0-9]*?)\\]");

    private KafkaRequest() {
    }

    /**
     * Creates a new Kafka request builder.
     */
    public static KafkaBuilder builder(KafkaEndpoint endpoint) {
        return new KafkaRequestBuilder(endpoint);
    }

    /**
     * Phase 1: Select direction (send or receive).
     */
    public interface KafkaBuilder {
        /**
         * Specifies the target topic for sending.
         *
         * @param topic Kafka topic name
         * @return KafkaPayloadPhase for payload configuration
         */
        KafkaPayloadPhase toTopic(String topic);

        /**
         * Specifies the source topic for receiving.
         *
         * @param topic Kafka topic name
         * @return KafkaReceiveFilterPhase for filter configuration
         */
        KafkaReceiveFilterPhase fromTopic(String topic);


    }

    /**
     * Phase 2a: Configure payload and headers for sending.
     */
    public interface KafkaPayloadPhase {
        /**
         * Sets the message key.
         *
         * @param key Message key
         * @return this for chaining
         */
        KafkaPayloadPhase withKey(String key);

        /**
         * Sets the payload as JSON string.
         *
         * @param json JSON payload
         * @return this for chaining
         */
        KafkaPayloadPhase withPayload(String json);

        /**
         * Loads payload from a file.
         *
         * @param path Path to JSON file in resources
         * @return this for chaining
         */
        KafkaPayloadPhase withPayloadFromFile(String path);

        /**
         * Loads payload from a template file and renders it.
         *
         * @param path      Path to template file
         * @param variables Variables for template rendering
         * @return this for chaining
         */
        KafkaPayloadPhase withPayloadFromTemplate(String path, Map<String, Object> variables);

        /**
         * Adds a field to the JSON payload.
         *
         * @param fieldName Field name
         * @param value     Field value
         * @return this for chaining
         */
        KafkaPayloadPhase addField(String fieldName, Object value);

        /**
         * Adds a field to a nested path in the JSON payload.
         *
         * @param path      Path to parent node (e.g., "items[1].details")
         * @param fieldName Field name to add
         * @param value     Field value
         * @return this for chaining
         */
        KafkaPayloadPhase addField(String path, String fieldName, Object value);

        /**
         * Appends a value to an array in the JSON payload.
         *
         * @param path  Path to array (e.g., "items")
         * @param value Value to append
         * @return this for chaining
         */
        KafkaPayloadPhase appendToArray(String path, Object value);

        /**
         * Adds W3C Trace Context traceparent header.
         *
         * @param value Traceparent value (e.g., "00-traceId-spanId-01")
         * @return this for chaining
         */
        KafkaPayloadPhase withTraceparent(String value);

        /**
         * Adds requestID header.
         *
         * @param value Request ID
         * @return this for chaining
         */
        KafkaPayloadPhase withRequestID(String value);

        /**
         * Adds activityID header.
         *
         * @param value Activity ID
         * @return this for chaining
         */
        KafkaPayloadPhase withActivityID(String value);

        /**
         * Adds sourceCodebookId header.
         *
         * @param value Source codebook ID
         * @return this for chaining
         */
        KafkaPayloadPhase withSourceCodebookId(String value);

        /**
         * Adds a custom header.
         *
         * @param key   Header name
         * @param value Header value
         * @return this for chaining
         */
        KafkaPayloadPhase withHeader(String key, String value);

        /**
         * Sends the message.
         */
        void send();
    }

    /**
     * Phase 2b: Configure filter for receiving.
     */
    public interface KafkaReceiveFilterPhase {
        /**
         * Specifies the starting point of consumption at the start of partitions.
         *
         * @return KafkaReceiveFilterPhase for filter configuration
         */
        KafkaReceiveFilterPhase startFromBeginning();

        /**
         * Specifies the starting point of consumption at the end of partitions
         *
         * @return KafkaReceiveFilterPhase for filter configuration
         */
        KafkaReceiveFilterPhase startFromEnd();

        /**
         * Specifies the filter predicate for receiving messages.
         *
         * @param filter Predicate to filter messages
         * @return KafkaAwaitingPhase for timeout configuration
         */
        KafkaAwaitingPhase receiveWhere(Predicate<ReceivedMessage> filter);
    }

    /**
     * Phase 3: Configure timeout.
     */
    public interface KafkaAwaitingPhase {
        /**
         * Sets the timeout for waiting for a message.
         *
         * @param duration Timeout duration
         * @param unit     Time unit
         * @return MessageResponse for assertions
         */
        MessageResponse withTimeout(long duration, TimeUnit unit);
    }

    /**
     * Internal implementation class.
     */
    private static class KafkaRequestBuilder implements
            KafkaBuilder,
            KafkaPayloadPhase, KafkaReceiveFilterPhase, KafkaAwaitingPhase,
            MessageResponse {

        private final KafkaEndpoint endpoint;

        // Send configuration
        private String topic;
        private String key;
        private String payload;
        private final Map<String, String> headers = new HashMap<>();

        // Receive configuration
        private Predicate<ReceivedMessage> filter;
        private Duration timeout;
        //default true
        private boolean startFromBeginning = true;
        // Response (after receive)
        private List<ReceivedMessage> messages;

        public KafkaRequestBuilder(KafkaEndpoint endpoint) {
            this.endpoint = endpoint;
        }

        // === Send phase ===

        @Override
        public KafkaPayloadPhase toTopic(String topic) {
            this.topic = topic;
            return this;
        }

        @Override
        public KafkaPayloadPhase withKey(String key) {
            this.key = key;
            return this;
        }

        @Override
        public KafkaPayloadPhase withPayload(String json) {
            this.payload = json;
            return this;
        }

        @Override
        public KafkaPayloadPhase withPayloadFromFile(String path) {
            this.payload = FileReader.readFileFromResources(path);
            return this;
        }

        @Override
        public KafkaPayloadPhase withPayloadFromTemplate(String path, Map<String, Object> variables) {
            String templateContent = FileReader.readFileFromResources(path);
            cz.moneta.test.harness.support.util.Template template =
                    new cz.moneta.test.harness.support.util.Template(templateContent);
            if (variables != null) {
                variables.forEach((k, v) -> template.set(k, v != null ? v.toString() : ""));
            }
            this.payload = template.render();
            return this;
        }

        @Override
        public KafkaPayloadPhase addField(String fieldName, Object value) {
            return addField("", fieldName, value);
        }

        @Override
        public KafkaPayloadPhase addField(String path, String fieldName, Object value) {
            try {
                if (payload == null) {
                    payload = "{}";
                }
                JsonNode rootNode = MAPPER.readTree(payload);
                JsonNode targetNode = extractNode(path, rootNode);
                JsonNode newNode = MAPPER.valueToTree(value);
                ((ObjectNode) targetNode).set(fieldName, newNode);
                payload = MAPPER.writeValueAsString(rootNode);
            } catch (Exception e) {
                throw new IllegalStateException("Failed to add field '" + fieldName + "' at path '" + path + "': " + e.getMessage(), e);
            }
            return this;
        }

        @Override
        public KafkaPayloadPhase appendToArray(String path, Object value) {
            try {
                if (payload == null) {
                    payload = "{}";
                }
                JsonNode rootNode = MAPPER.readTree(payload);
                JsonNode targetNode = extractNode(path, rootNode);
                JsonNode newNode = MAPPER.valueToTree(value);
                if (targetNode.isArray()) {
                    ((ArrayNode) targetNode).add(newNode);
                } else {
                    throw new IllegalStateException("Path '" + path + "' does not point to an array");
                }
                payload = MAPPER.writeValueAsString(rootNode);
            } catch (Exception e) {
                throw new IllegalStateException("Failed to append to array at path '" + path + "': " + e.getMessage(), e);
            }
            return this;
        }

        @Override
        public KafkaPayloadPhase withTraceparent(String value) {
            return withHeader("traceparent", value);
        }

        @Override
        public KafkaPayloadPhase withRequestID(String value) {
            return withHeader("requestID", value);
        }

        @Override
        public KafkaPayloadPhase withActivityID(String value) {
            return withHeader("activityID", value);
        }

        @Override
        public KafkaPayloadPhase withSourceCodebookId(String value) {
            return withHeader("sourceCodebookId", value);
        }

        @Override
        public KafkaPayloadPhase withHeader(String key, String value) {
            this.headers.put(key, value);
            return this;
        }

        @Override
        public void send() {
            if (topic == null) {
                throw new IllegalStateException("Topic not specified. Call toTopic() first.");
            }
            if (payload == null) {
                throw new IllegalStateException("Payload not specified. Call withPayload() or withPayloadFromFile() first.");
            }
            endpoint.send(topic, key, payload, headers);
        }

        // === Receive phase ===

        @Override
        public KafkaReceiveFilterPhase fromTopic(String topic) {
            this.topic = topic;
            return this;
        }

        @Override
        public KafkaReceiveFilterPhase startFromBeginning() {
            this.startFromBeginning = true;
            return this;
        }

        @Override
        public KafkaReceiveFilterPhase startFromEnd() {
            this.startFromBeginning = false;
            return this;
        }

        @Override
        public KafkaAwaitingPhase receiveWhere(Predicate<ReceivedMessage> filter) {
            this.filter = filter;
            return this;
        }

        @Override
        public MessageResponse withTimeout(long duration, TimeUnit unit) {
            this.timeout = Duration.of(duration, unit.toChronoUnit());
            messages = endpoint.receive(topic, filter, timeout, startFromBeginning);
            return this;
        }

        // === MessageResponse implementation ===

        @Override
        public MessageResponse andAssertFieldValue(String path, String value) {
            if (messages == null || messages.isEmpty()) {
                throw new IllegalStateException("No message received to assert on");
            }
            ReceivedMessage msg = messages.get(0);
            String actual = msg.extract(path);
            if (!Objects.equals(value, actual)) {
                throw new AssertionError(
                        String.format("Field '%s' has value '%s', expected '%s'", path, actual, value));
            }
            return this;
        }

        @Override
        public MessageResponse andAssertPresent(String path) {
            if (messages == null || messages.isEmpty()) {
                throw new IllegalStateException("No message received to assert on");
            }
            ReceivedMessage msg = messages.get(0);
            JsonNode node = msg.extractJson(path);
            if (node.isMissingNode()) {
                throw new AssertionError("Field '" + path + "' is missing");
            }
            return this;
        }

        @Override
        public MessageResponse andAssertNotPresent(String path) {
            if (messages == null || messages.isEmpty()) {
                throw new IllegalStateException("No message received to assert on");
            }
            ReceivedMessage msg = messages.get(0);
            JsonNode node = msg.extractJson(path);
            if (!node.isMissingNode()) {
                throw new AssertionError("Field '" + path + "' is present with value: " + node.asText());
            }
            return this;
        }

        @Override
        public MessageResponse andAssertHeaderValue(String headerName, String value) {
            if (messages == null || messages.isEmpty()) {
                throw new IllegalStateException("No message received to assert on");
            }
            ReceivedMessage msg = messages.get(0);
            String actual = msg.getHeader(headerName);
            if (!Objects.equals(value, actual)) {
                throw new AssertionError(
                        String.format("Header '%s' has value '%s', expected '%s'", headerName, actual, value));
            }
            return this;
        }

        @Override
        public MessageResponse andAssertBodyContains(String substring) {
            if (messages == null || messages.isEmpty()) {
                throw new IllegalStateException("No message received to assert on");
            }
            ReceivedMessage msg = messages.get(0);
            String body = msg.getBody();
            if (body == null || !body.contains(substring)) {
                throw new AssertionError(
                        String.format("Body does not contain '%s'. Actual body: %s", substring, body));
            }
            return this;
        }

        @Override
        public net.javacrumbs.jsonunit.assertj.JsonAssert.ConfigurableJsonAssert andAssertWithAssertJ() {
            if (messages == null || messages.isEmpty()) {
                throw new IllegalStateException("No message received to assert on");
            }
            return net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson(messages.get(0).getBody());
        }

        @Override
        public JsonPathValue extract(String path) {
            if (messages == null || messages.isEmpty()) {
                throw new IllegalStateException("No message received to extract from");
            }
            JsonNode node = messages.get(0).extractJson(path);
            return new JsonPathValueImpl(node);
        }

        @Override
        public <T> T mapTo(Class<T> type) {
            if (messages == null || messages.isEmpty()) {
                throw new IllegalStateException("No message received to map");
            }
            return messages.get(0).mapTo(type);
        }

        @Override
        public ReceivedMessage getMessage() {
            if (messages == null || messages.isEmpty()) {
                throw new IllegalStateException("No message received");
            }
            return messages.get(0);
        }

        @Override
        public String getBody() {
            if (messages == null || messages.isEmpty()) {
                return null;
            }
            return messages.get(0).getBody();
        }

        @Override
        public String getHeader(String name) {
            if (messages == null || messages.isEmpty()) {
                return null;
            }
            return messages.get(0).getHeader(name);
        }

        // === Helper methods ===

        private JsonNode extractNode(String path, JsonNode rootNode) {
            if (StringUtils.isBlank(path)) {
                return rootNode;
            }

            return Arrays.stream(path.split("\\."))
                    .filter(StringUtils::isNotEmpty)
                    .reduce(rootNode,
                            (r, p) -> {
                                Matcher matcher = ARRAY_NODE_PATTERN.matcher(p);
                                if (matcher.find()) {
                                    return r.path(matcher.group(1)).path(Integer.valueOf(matcher.group(2)));
                                } else {
                                    return r.path(p);
                                }
                            },
                            (j1, j2) -> j1);
        }
    }

    /**
     * Implementation of JsonPathValue.
     */
    private static class JsonPathValueImpl implements MessageResponse.JsonPathValue {

        private final JsonNode node;

        public JsonPathValueImpl(JsonNode node) {
            this.node = node;
        }

        @Override
        public String asText() {
            if (node == null || node.isMissingNode()) {
                return null;
            }
            return node.asText();
        }

        @Override
        public Integer asInt() {
            if (node == null || node.isMissingNode()) {
                return null;
            }
            return node.asInt();
        }

        @Override
        public Long asLong() {
            if (node == null || node.isMissingNode()) {
                return null;
            }
            return node.asLong();
        }

        @Override
        public Boolean asBoolean() {
            if (node == null || node.isMissingNode()) {
                return null;
            }
            return node.asBoolean();
        }

        @Override
        public boolean isMissing() {
            return node == null || node.isMissingNode();
        }
    }
}
