package com.customer.work.model;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.flowable.audit.api.AuditService;
import com.flowable.audit.api.runtime.AuditInstance;
import com.flowable.core.spring.security.SecurityUtils;
import com.flowable.platform.service.task.CompleteFormRepresentation;
import com.flowable.platform.service.task.PlatformTaskService;
import com.github.wnameless.json.flattener.JsonFlattener;
import com.github.wnameless.json.unflattener.JsonUnflattener;
import jakarta.mail.Address;
import org.assertj.core.api.Assertions;
import org.flowable.bpmn.model.*;
import org.flowable.bpmn.model.Process;
import org.flowable.cmmn.api.runtime.CaseInstance;
import org.flowable.cmmn.engine.CmmnEngine;
import org.flowable.common.engine.api.identity.AuthenticationContext;
import org.flowable.common.engine.impl.identity.Authentication;
import org.flowable.engine.ManagementService;
import org.flowable.engine.ProcessEngine;
import org.flowable.engine.TaskService;
import org.flowable.engine.history.HistoricProcessInstance;
import org.flowable.engine.runtime.ProcessInstance;
import org.flowable.job.service.impl.persistence.entity.TimerJobEntity;
import org.flowable.spring.security.SpringSecurityAuthenticationContext;
import org.flowable.task.api.Task;
import org.flowable.variable.api.history.HistoricVariableInstance;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.platform.commons.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestClient;

import java.io.*;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Component
public class FlowableModelTestUtils {

    protected final ProcessEngine processEngine;
    protected final CmmnEngine cmmnEngine;
    protected final TaskService taskService;
    protected final PlatformTaskService platformTaskService;
    protected final ManagementService managementService;
    protected final FlowableExcelMapper flowableExcelMapper;
    protected static final Logger logger = LoggerFactory.getLogger(FlowableModelTestUtils.class);
    protected final TestMailServer testMailServer;
    protected final AuditService auditService;
    protected final ObjectMapper objectMapper = new ObjectMapper();


    public static final String TENANT_ID = null;
    public static final String ROOT_PROCESS_ID = "ROOT_PROCESS_ID";
    public static final String TEST_PROCESS_ID = "TEST_PROCESS_ID";

    public static final String ROOT_PARAM = "__ROOT";
    public static final String IN_PARAM = "__IN";
    public static final String OUT_PARAM = "__OUT";


    public FlowableModelTestUtils(ProcessEngine processEngine,
                                  CmmnEngine cmmnEngine,
                                  TaskService taskService,
                                  PlatformTaskService platformTaskService,
                                  ManagementService managementService,
                                  FlowableExcelMapper flowableExcelMapper,
                                  TestMailServer testMailServer,
                                  AuditService auditService) {
        this.processEngine = processEngine;
        this.cmmnEngine = cmmnEngine;
        this.taskService = taskService;
        this.platformTaskService = platformTaskService;
        this.managementService = managementService;
        this.flowableExcelMapper = flowableExcelMapper;
        this.testMailServer = testMailServer;
        this.auditService = auditService;
        // Enable ObjectMapper for handling Instant as string
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
    }

    protected Map<String, Object> convertJsonNodeToMap(JsonNode jsonNode) {
        return objectMapper.convertValue(jsonNode, new TypeReference<>() {});
    }

    protected JsonNode convertMapToJsonNode(Map<String, Object> map) {
        return objectMapper.valueToTree(map);
    }

    protected Map<String, Object> flatten(Object payload) {
        try {
            return JsonFlattener.flattenAsMap(objectMapper.writeValueAsString(payload));
        } catch (JsonProcessingException e) {
            throw new UncheckedIOException("Cannot flatten: " + payload, e);
        }
    }

    protected Map<String, Object> unflatten(Map<String, Object> flatVars) {
        try {
            return objectMapper.readValue(JsonUnflattener.unflatten(flatVars), new TypeReference<>() {});
        } catch (JsonProcessingException e) {
            throw new UncheckedIOException("Cannot unflatten: " + flatVars, e);
        }
    }

    public void checkAndAssertAuditRecord(Map<String, Object> map, int auditNumber, String hint, String message, String category, String type) {
        if (map != null) {
            Object check = map.get("audit");
            if (check instanceof Integer && (Integer) check > 0) {
                assertAuditRecord(auditNumber, hint, message, category, type);
            }
        }
    }

    protected void assertAuditRecord(int auditNumber, String hint, String message, String category, String type) {
        if (auditNumber <= 0) return;
        List<AuditInstance> auditTrail = getAuditTrail();
        Assertions.assertThat(auditTrail.size()).as("%s: invalid auditNumber %s", hint, auditNumber).isGreaterThanOrEqualTo(auditNumber);

        AuditInstance auditInstance = auditTrail.get(auditNumber - 1);
        Assertions.assertThat(auditInstance.getPayload().get("message")).as(hint).isEqualTo(message);
        Assertions.assertThat(auditInstance.getPayload().get("category")).as(hint).isEqualTo(category);
        Assertions.assertThat(auditInstance.getType()).as(hint).isEqualTo(type);
    }

    public void checkAndAssertEmail(Map<String, Object> map, int emailNumber, String hint, String subject, String receivers) {
        if (map != null) {
            Object check = map.get("email");
            if (check instanceof Integer && (Integer) check > 0) {
                assertEmail(emailNumber, hint, subject, receivers);
            }
        }
    }

    protected void assertEmail(int emailNumber, String hint, String subject, String receivers) {
        if (emailNumber <= 0) return;
        List<EmailDto> emails = getMailList();
        Assertions.assertThat(emails.size()).as("%s: invalid email number %s", hint, emailNumber).isGreaterThanOrEqualTo(emailNumber);

        EmailDto email = emails.get(emailNumber - 1);
        Assertions.assertThat(email.subject()).as("%s: invalid subject %s", hint, email.subject()).endsWith(subject);
        Assertions.assertThat(email.receiverList()).as("%s: invalid email receivers", hint)
                .containsExactlyInAnyOrder(receivers.split("[,\\s]+"));
    }

    public Map<String, Object> getHistoryCasePayload(String caseInstanceId) {
        List<HistoricVariableInstance> historicVariableInstanceList = cmmnEngine.getCmmnHistoryService()
                .createHistoricVariableInstanceQuery()
                .caseInstanceId(caseInstanceId)
                .list();
        return convertHistVariableListToMap(historicVariableInstanceList);
    }

    public Map<String, Object> getRuntimeCasePayload(String caseId) {
        return cmmnEngine.getCmmnRuntimeService().getVariables(caseId);
    }

    public CaseInstance startCaseInstance(String key, Map<String, Object> variables) {
        return cmmnEngine.getCmmnRuntimeService()
                .createCaseInstanceBuilder()
                .caseDefinitionKey(key)
                .tenantId(TENANT_ID) // Must not be "default"
                .variables(variables)
                .start();
    }

    public ProcessInstance startProcessInstance(String key, Map<String, Object> variables) {
        return processEngine.getRuntimeService()
                .createProcessInstanceBuilder()
                .processDefinitionKey(key)
                .tenantId(TENANT_ID) // Must not be "default"
                .variables(variables)
                .start();
    }

    public Task getOpenTask(String taskKey) {
        Task task = taskService.createTaskQuery().taskDefinitionKey(taskKey).singleResult();
        Assertions.assertThat(task).as("No open task with key %s found", taskKey).isNotNull();
        return task;
    }

    public Task getOpenTaskByName(String taskName) {
        Task task = taskService.createTaskQuery().taskName(taskName).singleResult();
        Assertions.assertThat(task).as("No open task with name %s found", taskName).isNotNull();
        return task;
    }

    public void claimOpenTask(String taskKey, String userId) {
        setTestAuthenticatedUser(userId, TENANT_ID);
        taskService.claim(getOpenTask(taskKey).getId(), userId);
    }

    public List<AuditInstance> getAuditTrail() {
        return auditService.createAuditInstanceQuery()
                //.tenantId(TENANT_ID) TODO: Must not be null, should work with default tenant
                .list();
    }

    public List<EmailDto> getMailList() {
        return Arrays.stream(testMailServer.getMessages()).map(mimeMessage -> {
            try {
                return new EmailDto(mimeMessage.getSubject(), Arrays.stream(mimeMessage.getAllRecipients())
                        .map(Address::toString).collect(Collectors.toList()), mimeMessage.getContent().toString(), mimeMessage.getContent());
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }).collect(Collectors.toList());
    }

    public boolean isSubset(JsonNode root, JsonNode test) {
        // If test is null, it is always a subset of root
        if (test == null || test.isNull()) {
            return true;
        }
        // If test is a value node, compare values
        if (test.isValueNode()) {
            if (test.isTextual() && "__N_A".equals(test.asText())) {
                return false;
            }
            if (root == null) {
                return false;
            }
            return root.isValueNode() && root.asText().equals(test.asText());
        }
        // If test is an array node, check if all elements of test exist in root
        if(test.isArray()){
            for (int i = 0; i < test.size(); i++) {
                JsonNode testElement = test.get(i);
                JsonNode rootElement = null;
                if (root != null) {
                    rootElement = root.get(i);
                }
                if (testElement.isTextual() && "__N_A".equals(testElement.asText())) {
                    if (rootElement != null && !rootElement.isNull()) {
                        return false;
                    }
                    continue;
                }
                if (!isSubset(rootElement, testElement)) {
                    return false;
                }
            }
            return true;
        }
        // If test is an object node, check if all fields in test exist in root
        if (test.isObject()) {
            for (Map.Entry<String, JsonNode> testField : test.properties()) {
                String fieldName = testField.getKey();
                JsonNode testValue = testField.getValue();
                JsonNode rootValue = root != null ? root.get(fieldName) : null;
                if (testValue.isTextual() && "__N_A".equals(testValue.asText())) {
                    if (root != null && root.has(fieldName)) {
                        return false;
                    }
                    continue;
                }
                if (!isSubset(rootValue, testValue)){
                    return false;
                }
            }
            return true;
        }
        // If none of the above matches, return false
        return false;
    }

    public Stream<Arguments> getJsonArgumentsFromExcel(String path) {
        ArrayList<Arguments> argumentList = new ArrayList<>();
        JsonNode book = flowableExcelMapper.excelBookResourceToJsonNode(path);
        for (JsonNode sheet : book) {
            for (JsonNode row : sheet) {
                argumentList.add(Arguments.of(path, row));
            }
        }
        return argumentList.stream();
    }

    public Stream<Arguments> getObjArgumentsFromExcel(String path) {
        ArrayList<Arguments> argumentList = new ArrayList<>();
        for (List<Map<String, Object>> sheet : flowableExcelMapper.excelBookResourceToObj(path)) {
            for (Map<String, Object> row : sheet) {
                argumentList.add(Arguments.of(path, row));
            }
        }
        return argumentList.stream();
    }

    public ObjectNode testJsonExcelRow(String path, JsonNode row) {
        ObjectNode vars = objectMapper.createObjectNode();
        JsonNode rootParam = row.get("root");
        if (rootParam != null) {
            for (Map.Entry<String, JsonNode> rootVar : rootParam.properties()) {
                vars.set(rootVar.getKey(), rootVar.getValue());
            }
        }
        JsonNode inParam = row.get("in");
        if (inParam != null) {
            vars.set(IN_PARAM, inParam);
        }
        JsonNode outParam = row.get("out");
        if (outParam != null) {
            vars.set(OUT_PARAM, outParam);
        }
        JsonNode idParam = row.get("id");
        JsonNode test = row.get("test");
        int timerCount = 0;
        JsonNode timerNode = row.get("timer");
        if (timerNode != null) timerCount = timerNode.asInt();
        Integer auditRecordCount = null;
        JsonNode auditNode = row.get("audit");
        if (auditNode != null) auditRecordCount = auditNode.asInt();
        Integer emailCount = null;
        JsonNode emailNode = row.get("email");
        if (emailNode != null) emailCount = emailNode.asInt();

        logger.info("TestExcelRow file={} id={} rootParam={} inParam={} outParam={} timer={} test={} audit={} email={} ",
                path, idParam, rootParam, inParam, outParam, timerCount, test, auditRecordCount, emailCount);
        Assertions.assertThat(idParam).as("Column 'id' missing in row of %s", path).isNotNull();
        ObjectNode processIds = createRootTestProcessInstance(idParam.asText(), convertJsonNodeToMap(vars));
        for (int i = 0; i < timerCount; i++) {
            executeTimer(processIds.get(TEST_PROCESS_ID).asText());
        }

        ObjectNode result = objectMapper.createObjectNode();
        Map<String, Object> rootProcessPayload = getHistProcessPayload(processIds.get(FlowableModelTestUtils.ROOT_PROCESS_ID).asText());
        JsonNode root = convertMapToJsonNode(rootProcessPayload);
        if (root != null) {
            Assertions.assertThat(isSubset(root, test))
                    .withFailMessage(System.lineSeparator() + "test :" + test + System.lineSeparator() + "root :" + root).isTrue();
            result.set("root", root);
        }
        if (auditRecordCount != null) {
            List<AuditInstance> auditTrail = getAuditTrail();
            Assertions.assertThat(auditTrail.size()).as("Invalid audit trail size: %s", auditTrail.size()).isEqualTo(auditRecordCount);
            result.set("auditTrail", objectMapper.valueToTree(Collections.singletonList(auditTrail)));
        }
        if (emailCount != null) {
            List<EmailDto> emailList = getMailList();
            Assertions.assertThat(emailList.size()).as("Invalid number of emails: %s", emailList.size()).isEqualTo(emailCount);
            result.set("emails", objectMapper.valueToTree(Collections.singletonList(emailList)));
        }
        return result;
    }

    public Map<String, Object> testObjExcelRow(String path, Map<String, Object> row) {
        Map<String, Object> vars = new LinkedHashMap<>();
        Map<String, Object> rootParam = getMapParam(row, "root", path);
        if (rootParam != null) {
            vars.putAll(rootParam);
        }
        Map<String, Object> inParam = getMapParam(row, "in", path);
        if (inParam != null) {
            vars.put(IN_PARAM, inParam);
        }
        Map<String, Object> outParam = getMapParam(row, "out", path);
        if (outParam != null) {
            vars.put(OUT_PARAM, outParam);
        }
        String idParam = (String) row.get("id");
        Map<String, Object> test = getMapParam(row, "test", path);
        int timerCount = 0;
        Object timerNode = row.get("timer");
        if (timerNode != null) timerCount = (Integer) timerNode;
        Integer auditRecordCount = null;
        Object auditNode = row.get("audit");
        if (auditNode != null) auditRecordCount = (Integer) auditNode;
        Integer emailCount = null;
        Object emailNode = row.get("email");
        if (emailNode != null) emailCount = (Integer) emailNode;

        logger.info("TestExcelRow file={} id={} rootParam={} inParam={} outParam={} timer={} test={} audit={} email={} ",
                path, idParam, rootParam, inParam, outParam, timerCount, test, auditRecordCount, emailCount);
        Assertions.assertThat(idParam).as("Column 'id' missing in row of %s", path).isNotNull();
        ObjectNode processIds = createRootTestProcessInstance(idParam, vars);
        for (int i = 0; i < timerCount; i++) {
            executeTimer(processIds.get(TEST_PROCESS_ID).asText());
        }

        Map<String, Object> result = new LinkedHashMap<>();
        Map<String, Object> rootProcessPayload = getHistProcessPayload(processIds.get(FlowableModelTestUtils.ROOT_PROCESS_ID).asText());
        JsonNode root = convertMapToJsonNode(rootProcessPayload);
        if (root != null) {
            Assertions.assertThat(isSubset(root, convertMapToJsonNode(test)))
                    .withFailMessage(System.lineSeparator() + "test :" + test + System.lineSeparator() + "root :" + root).isTrue();
            result.put("root", rootProcessPayload);
        }
        if (auditRecordCount != null) {
            List<AuditInstance> auditTrail = getAuditTrail();
            Assertions.assertThat(auditTrail.size()).as("Invalid audit trail size: %s", auditTrail.size()).isEqualTo(auditRecordCount);
            result.put("audit", auditRecordCount);
        }
        if (emailCount != null) {
            List<EmailDto> emailList = getMailList();
            Assertions.assertThat(emailList.size()).as("Invalid number of emails: %s", emailList.size()).isEqualTo(emailCount);
            result.put("email", emailCount);
        }
        return result;
    }

    @SuppressWarnings("unchecked")
    protected Map<String, Object> getMapParam(Map<String, Object> row, String key, String path) {
        Object value = row.get(key);
        if (value == null) {
            return null;
        }
        Assertions.assertThat(value).as("Parameter '%s' in %s must be an object", key, path).isInstanceOf(Map.class);
        return (Map<String, Object>) value;
    }

    public void exportApp(String rootUrl, String workspaceKey, String appModelKey, String username, String password) {
        RestClient restClient = RestClient.builder()
                .baseUrl(rootUrl)
                .defaultHeaders(h -> h.setBasicAuth(username, password))
                .build();

        File file = Paths.get("src", "test", "resources", "test-auto-deploy-apps", appModelKey + ".zip").toFile();
        restClient.get()
                .uri("/design-api/workspaces/" + workspaceKey + "/apps/" + appModelKey + "/export?excludeChildReferences=false")
                .exchange((req, resp) -> {
                    try (FileOutputStream outputStream = new FileOutputStream(file, false)) {
                        StreamUtils.copy(resp.getBody(), outputStream);
                    }
                    return file;
                });
    }

    public ObjectNode emptyNode() {
        return objectMapper.createObjectNode();
    }

    public Map<String, Object>  emptyMap() {
        return new LinkedHashMap<>();
    }

    public ObjectNode loadObjectNodeFromResources(String path) {
        if (path == null || path.isEmpty()) return objectMapper.createObjectNode();
        try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(path)) {
            if (inputStream == null) {
                throw new IllegalArgumentException("Resource not found: " + path);
            }
            JsonNode jsonNode = objectMapper.readTree(inputStream);
            Assertions.assertThat(jsonNode).as("Resource %s is not a JSON object", path).isInstanceOf(ObjectNode.class);
            return (ObjectNode) jsonNode;
        } catch (IOException e) {
            throw new UncheckedIOException("Cannot read resource: " + path, e);
        }
    }

    /**
     * Loads process start variables from a JSON resource. All parameters must be
     * declared explicitly: root process variables inside "__ROOT", call activity
     * in/out mappings inside "__IN" and "__OUT". Any other top-level field fails.
     */
    public Map<String, Object> loadTestVarsFromResources(String path) {
        ObjectNode node = loadObjectNodeFromResources(path);
        Map<String, Object> vars = new LinkedHashMap<>();
        for (Map.Entry<String, JsonNode> field : node.properties()) {
            String key = field.getKey();
            switch (key) {
                case ROOT_PARAM -> {
                    Assertions.assertThat(field.getValue().isObject())
                            .as("%s in %s must be a JSON object", ROOT_PARAM, path).isTrue();
                    for (Map.Entry<String, JsonNode> rootVar : field.getValue().properties()) {
                        Assertions.assertThat(rootVar.getKey().startsWith("__"))
                                .as("Root parameter '%s' in %s must not start with '__'", rootVar.getKey(), path).isFalse();
                    }
                    vars.putAll(convertJsonNodeToMap(field.getValue()));
                }
                case IN_PARAM, OUT_PARAM -> vars.put(key, convertJsonNodeToMap(field.getValue()));
                default -> throw new IllegalArgumentException("Implicit parameter '" + key + "' in " + path
                        + ": parameters must be declared explicitly inside " + ROOT_PARAM + ", " + IN_PARAM + " or " + OUT_PARAM);
            }
        }
        return vars;
    }

    public void executeTimer(String processId) {
        TimerJobEntity timerJobEntity = (TimerJobEntity) managementService.createTimerJobQuery().processInstanceId(processId).singleResult();
        Assertions.assertThat(timerJobEntity).withFailMessage("No active timer").isNotNull();
        managementService.moveTimerToExecutableJob(timerJobEntity.getId());
        managementService.executeJob(timerJobEntity.getId());
    }
    public void setTestAuthenticatedUser(String userId, String tenantId, String... groupKeys) {
        if (userId == null) {
            this.resetTestAuthenticatedUser();
        } else {
            List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            if (StringUtils.isNotBlank(tenantId)) {
                grantedAuthorities.add(SecurityUtils.createTenantAuthority(tenantId));
            }

            for (String groupKey : groupKeys) {
                grantedAuthorities.add(SecurityUtils.createGroupAuthority(groupKey));
            }

            SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken(userId, "", grantedAuthorities));
            AuthenticationContext authenticationContext = Authentication.getAuthenticationContext();
            if (!(authenticationContext instanceof SpringSecurityAuthenticationContext)) {
                Authentication.setAuthenticatedUserId(userId);
            }
        }
    }

    public void resetTestAuthenticatedUser() {
        SecurityContextHolder.getContext().setAuthentication(null);
        AuthenticationContext authenticationContext = Authentication.getAuthenticationContext();
        if (!(authenticationContext instanceof SpringSecurityAuthenticationContext)) {
            Authentication.setAuthenticatedUserId(null);
        }
    }

    public void completeTaskWithFlatVars(String taskId, Map<String, Object> variables, String outcome) {
        Map<String, Object> taskVariables = platformTaskService.getTaskVariables(taskId);
        Map<String, Object> taskVariablesFlat = flatten(taskVariables);
        taskVariablesFlat.putAll(variables);
        Map<String, Object> completionVars = unflatten(taskVariablesFlat);

        completeTask(taskId, completionVars, outcome);
    }

    public void completeTask(String taskId, Map<String, Object> completionVariables, String outcome) {
        CompleteFormRepresentation form = new CompleteFormRepresentation();
        for (Map.Entry<String, Object> entry : completionVariables.entrySet()) {
            form.setValues(entry.getKey(), entry.getValue());
        }
        form.setOutcome(outcome);
        platformTaskService.completeTaskForm(taskId, form);
    }

    public void completeOpenTask(String taskKey, ObjectNode vars, String outcome) {
        Task task = getOpenTask(taskKey);
        Map<String, Object> flatVars = convertJsonNodeToMap(vars);
        completeTaskWithFlatVars(task.getId(), flatVars, outcome);
    }

    public Map<String, Object> getHistProcessPayload(String processInstanceId) {
        List<HistoricVariableInstance> historicVariableInstanceList = processEngine.getHistoryService()
                .createHistoricVariableInstanceQuery()
                .processInstanceId(processInstanceId)
                .list();
        return convertHistVariableListToMap(historicVariableInstanceList);
    }

    private Map<String, Object> convertHistVariableListToMap(List<HistoricVariableInstance> historicVariableInstanceList) {
        Map<String, Object> variableMap = new HashMap<>();
        for (HistoricVariableInstance historicVariableInstance : historicVariableInstanceList) {
            variableMap.put(historicVariableInstance.getVariableName(), historicVariableInstance.getValue());
        }
        return variableMap;
    }

    public ObjectNode createRootTestProcessInstance(String testKey, Map<String, Object> variables) {
        // Create root process with test process as call activity
        String wrapperProcessKey = testKey + "_T";
        BpmnModel bpmnModel = createWrapperTestProcessModel(testKey, (ObjectNode) convertMapToJsonNode(variables));
        processEngine.getProcessEngineConfiguration()
                .getRepositoryService()
                .createDeployment()
                .addBpmnModel(wrapperProcessKey + ".bpmn20.xml", bpmnModel)
                .tenantId(TENANT_ID) // Must not be "default"
                .deploy();

        // Start root process
        ProcessInstance rootProcessInstance = startProcessInstance(wrapperProcessKey, variables);

        // Store the process IDs for using them in assertions
        ProcessInstance runtimeTestProcessInstance = processEngine.getRuntimeService()
                .createProcessInstanceQuery()
                .superProcessInstanceId(rootProcessInstance.getId())
                .singleResult();
        String testProcessId;
        if (runtimeTestProcessInstance != null) {
            testProcessId = runtimeTestProcessInstance.getId();
        } else {
            HistoricProcessInstance historicTestProcessInstance = processEngine.getHistoryService()
                    .createHistoricProcessInstanceQuery()
                    .superProcessInstanceId(rootProcessInstance.getId())
                    .singleResult();
            // if history level is none, testProcessId is not available
            testProcessId = historicTestProcessInstance != null ? historicTestProcessInstance.getId() : null;
        }
        ObjectNode rootNode = objectMapper.createObjectNode();
        rootNode.put(ROOT_PROCESS_ID, rootProcessInstance.getProcessInstanceId());
        rootNode.put(TEST_PROCESS_ID, testProcessId);
        return rootNode;
    }

    protected BpmnModel createWrapperTestProcessModel(String testKey, ObjectNode variables) {
        BpmnModel bpmnModel = new BpmnModel();
        Process process = new Process();
        process.setId(testKey + "_T");
        process.setName(testKey + "_T");

        StartEvent startEvent = new StartEvent();
        startEvent.setId(testKey + "_Start");
        startEvent.setName(testKey + "_Start");
        process.addFlowElement(startEvent);

        CallActivity callActivity = new CallActivity();
        callActivity.setId(testKey);
        callActivity.setCalledElement(testKey);
        if (variables.get(IN_PARAM) instanceof ObjectNode inNode) {
            List<IOParameter> inParameters = new ArrayList<>();
            for (Map.Entry<String, JsonNode> entry : inNode.properties()) {
                IOParameter ioParameter = new IOParameter();
                ioParameter.setSourceExpression("${" + IN_PARAM + "." + entry.getKey() + "}");
                ioParameter.setTarget(entry.getKey());
                inParameters.add(ioParameter);
            }
            callActivity.setInParameters(inParameters);
        }
        if (variables.get(OUT_PARAM) instanceof ObjectNode outNode) {
            List<IOParameter> outParameters = new ArrayList<>();
            for (Map.Entry<String, JsonNode> entry : outNode.properties()) {
                IOParameter ioParameter = new IOParameter();
                ioParameter.setSource(entry.getKey());
                ioParameter.setTarget(entry.getValue().asText());
                outParameters.add(ioParameter);
            }
            callActivity.setOutParameters(outParameters);
        }
        process.addFlowElement(callActivity);

        EndEvent endEvent = new EndEvent();
        endEvent.setId(testKey + "_End");
        endEvent.setName(testKey + "_End");
        process.addFlowElement(endEvent);

        SequenceFlow start = new SequenceFlow(testKey + "_Start", testKey);
        start.setId("Start_Sequence_Flow");
        process.addFlowElement(start);

        SequenceFlow end = new SequenceFlow(testKey, testKey + "_End");
        end.setId("End_Sequence_Flow");
        process.addFlowElement(end);

        bpmnModel.addProcess(process);
        return bpmnModel;
    }

}
