/*
 * Copyright (c) "Neo4j"
 * Neo4j Sweden AB [http://neo4j.com]
 *
 * This file is part of Neo4j.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package apoc.refactor;

import static apoc.refactor.util.PropertiesManager.mergeProperties;
import static apoc.refactor.util.RefactorConfig.RelationshipSelectionStrategy.MERGE;
import static apoc.refactor.util.RefactorUtil.*;
import static apoc.util.Util.withTransactionAndRebind;
import static java.util.stream.StreamSupport.stream;

import apoc.Pools;
import apoc.algo.Cover;
import apoc.refactor.util.PropertiesManager;
import apoc.refactor.util.RefactorConfig;
import apoc.util.Util;
import apoc.util.collection.Iterables;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.neo4j.graphdb.*;
import org.neo4j.graphdb.schema.ConstraintType;
import org.neo4j.internal.kernel.api.procs.ProcedureCallContext;
import org.neo4j.kernel.api.QueryLanguage;
import org.neo4j.kernel.api.procedure.QueryLanguageScope;
import org.neo4j.kernel.impl.coreapi.InternalTransaction;
import org.neo4j.logging.Log;
import org.neo4j.procedure.*;

public class GraphRefactoring {
    @Context
    public Transaction tx;

    @Context
    public GraphDatabaseService db;

    @Context
    public Log log;

    @Context
    public Pools pools;

    @Context
    public ProcedureCallContext procedureCallContext;

    @Procedure(name = "apoc.refactor.extractNode", mode = Mode.WRITE)
    @Description("Expands the given `RELATIONSHIP` VALUES into intermediate `NODE` VALUES.\n"
            + "The intermediate `NODE` values are connected by the given `outType` and `inType`.")
    public Stream<NodeRefactorResult> extractNode(
            @Name(
                            value = "rels",
                            description =
                                    "The relationships to turn into new nodes. Relationships can be of type `STRING` (elementId()), `INTEGER` (id()), `RELATIONSHIP`, or `LIST<STRING | INTEGER | RELATIONSHIP>`.")
                    Object rels,
            @Name(value = "labels", description = "The labels to be added to the new nodes.") List<String> labels,
            @Name(value = "outType", description = "The type of the outgoing relationship.") String outType,
            @Name(value = "inType", description = "The type of the ingoing relationship.") String inType) {
        return Util.relsStream((InternalTransaction) tx, rels).map((rel) -> {
            NodeRefactorResult result = new NodeRefactorResult(rel.getId());
            try {
                Node copy = withTransactionAndRebind(db, tx, transaction -> {
                    Node copyNode = copyProperties(rel, transaction.createNode(Util.labels(labels)));
                    copyNode.createRelationshipTo(rel.getEndNode(), RelationshipType.withName(outType));
                    return copyNode;
                });
                rel.getStartNode().createRelationshipTo(copy, RelationshipType.withName(inType));
                rel.delete();
                return result.withOther(copy);
            } catch (Exception e) {
                return result.withError(e);
            }
        });
    }

    @Procedure(name = "apoc.refactor.collapseNode", mode = Mode.WRITE)
    @Description("Collapses the given `NODE` and replaces it with a `RELATIONSHIP` of the given type.")
    public Stream<UpdatedRelationshipResult> collapseNode(
            @Name(
                            value = "nodes",
                            description =
                                    "The nodes to collapse. Nodes can be of type `STRING` (elementId()), `INTEGER` (id()), `NODE`, or `LIST<STRING | INTEGER | NODE>`.")
                    Object nodes,
            @Name(value = "relType", description = "The name of the resulting relationship type.") String type) {
        return Util.nodeStream((InternalTransaction) tx, nodes).map((node) -> {
            UpdatedRelationshipResult result = new UpdatedRelationshipResult(node.getId());
            try {
                Iterable<Relationship> outRels = node.getRelationships(Direction.OUTGOING);
                Iterable<Relationship> inRels = node.getRelationships(Direction.INCOMING);
                if (node.getDegree(Direction.OUTGOING) == 1 && node.getDegree(Direction.INCOMING) == 1) {
                    Relationship outRel = outRels.iterator().next();
                    Relationship inRel = inRels.iterator().next();
                    Relationship newRel = inRel.getStartNode()
                            .createRelationshipTo(outRel.getEndNode(), RelationshipType.withName(type));
                    newRel = copyProperties(node, copyProperties(inRel, copyProperties(outRel, newRel)));

                    for (Relationship r : inRels) r.delete();
                    for (Relationship r : outRels) r.delete();
                    node.delete();

                    return result.withOther(newRel);
                } else {
                    return result.withError(String.format(
                            "Node %d has more that 1 outgoing %d or incoming %d relationships",
                            node.getId(), node.getDegree(Direction.OUTGOING), node.getDegree(Direction.INCOMING)));
                }
            } catch (Exception e) {
                return result.withError(e);
            }
        });
    }

    /**
     * this procedure takes a list of nodes and clones them with their labels and properties
     */
    @Procedure(name = "apoc.refactor.cloneNodes", mode = Mode.WRITE)
    @Description(
            "Clones the given `NODE` values with their labels and properties.\n"
                    + "It is possible to skip any `NODE` properties using skipProperties (note: this only skips properties on `NODE` values and not their `RELATIONSHIP` values).")
    public Stream<NodeRefactorResult> cloneNodes(
            @Name(value = "nodes", description = "The nodes to be cloned.") List<Node> nodes,
            @Name(
                            value = "withRelationships",
                            defaultValue = "false",
                            description = "Whether or not the connected relationships should also be cloned.")
                    boolean withRelationships,
            @Name(
                            value = "skipProperties",
                            defaultValue = "[]",
                            description = "Whether or not to skip the node properties when cloning.")
                    List<String> skipProperties) {
        if (nodes == null) return Stream.empty();
        return nodes.stream().map(node -> {
            NodeRefactorResult result = new NodeRefactorResult(node.getId());
            Node newNode = tx.createNode(Util.getLabelsArray(node));
            Map<String, Object> properties = node.getAllProperties();
            if (skipProperties != null && !skipProperties.isEmpty()) {
                for (String skip : skipProperties) properties.remove(skip);
            }
            try {
                copyProperties(properties, newNode);
                if (withRelationships) {
                    copyRelationships(node, newNode, false, true);
                }
            } catch (Exception e) {
                // If there was an error, the procedure still passes, but this node + its rels should not
                // be created. Instead, an error is returned to the user in the output.
                if (withRelationships) {
                    for (Relationship rel : newNode.getRelationships()) {
                        rel.delete();
                    }
                }
                newNode.delete();
                return result.withError(e);
            }
            return result.withOther(newNode);
        });
    }

    /**
     * this procedure clones a subgraph defined by a list of nodes and relationships. The resulting clone is a disconnected subgraph,
     * with no relationships connecting with the original nodes, nor with any other node outside the subgraph clone.
     * This can be overridden by supplying a list of node pairings in the `standinNodes` config property, so any relationships that went to the old node, when cloned, will instead be redirected to the standin node.
     * This is useful when instead of cloning a certain node or set of nodes, you want to instead redirect relationships in the resulting clone
     * such that they point to some existing node in the graph.
     *
     * For example, this could be used to clone a branch from a tree structure (with none of the new relationships going
     * to the original nodes) and to redirect any relationships from an old root node (which will not be cloned) to a different existing root node, which acts as the standin.
     *
     */
    @Procedure(name = "apoc.refactor.cloneSubgraphFromPaths", mode = Mode.WRITE)
    @Description(
            "Clones a sub-graph defined by the given `LIST<PATH>` values.\n"
                    + "It is possible to skip any `NODE` properties using the `skipProperties` `LIST<STRING>` via the config `MAP`.")
    public Stream<NodeRefactorResult> cloneSubgraphFromPaths(
            @Name(value = "paths", description = "The paths to be cloned.") List<Path> paths,
            @Name(
                            value = "config",
                            defaultValue = "{}",
                            description =
                                    """
                    {
                        standinNodes :: LIST<LIST<NODE>>,
                        skipProperties :: LIST<STRING>
                    }
                    """)
                    Map<String, Object> config) {

        if (paths == null || paths.isEmpty()) return Stream.empty();

        Set<Node> nodes = new HashSet<>();
        Set<Relationship> rels = new HashSet<>();

        for (Path path : paths) {
            for (Relationship rel : path.relationships()) {
                rels.add(rel);
            }

            for (Node node : path.nodes()) {
                nodes.add(node);
            }
        }

        List<Node> nodesList = new ArrayList<>(nodes);
        List<Relationship> relsList = new ArrayList<>(rels);

        return cloneSubgraph(nodesList, relsList, config);
    }

    /**
     * this procedure clones a subgraph defined by a list of nodes and relationships. The resulting clone is a disconnected subgraph,
     * with no relationships connecting with the original nodes, nor with any other node outside the subgraph clone.
     * This can be overridden by supplying a list of node pairings in the `standinNodes` config property, so any relationships that went to the old node, when cloned, will instead be redirected to the standin node.
     * This is useful when instead of cloning a certain node or set of nodes, you want to instead redirect relationships in the resulting clone
     * such that they point to some existing node in the graph.
     *
     * For example, this could be used to clone a branch from a tree structure (with none of the new relationships going
     * to the original nodes) and to redirect any relationships from an old root node (which will not be cloned) to a different existing root node, which acts as the standin.
     *
     */
    @Procedure(name = "apoc.refactor.cloneSubgraph", mode = Mode.WRITE)
    @Description(
            "Clones the given `NODE` values with their labels and properties (optionally skipping any properties in the `skipProperties` `LIST<STRING>` via the config `MAP`), and clones the given `RELATIONSHIP` values.\n"
                    + "If no `RELATIONSHIP` values are provided, all existing `RELATIONSHIP` values between the given `NODE` values will be cloned.")
    public Stream<NodeRefactorResult> cloneSubgraph(
            @Name(value = "nodes", description = "The nodes to be cloned.") List<Node> nodes,
            @Name(
                            value = "rels",
                            defaultValue = "[]",
                            description =
                                    "The relationships to be cloned. If left empty all relationships between the given nodes will be cloned.")
                    List<Relationship> rels,
            @Name(
                            value = "config",
                            defaultValue = "{}",
                            description =
                                    """
                    {
                        standinNodes :: LIST<LIST<NODE>>,
                        skipProperties :: LIST<STRING>,
                        createNodesInNewTransactions = false :: BOOLEAN
                    }
                    """)
                    Map<String, Object> config) {

        if (nodes == null || nodes.isEmpty()) return Stream.empty();

        final var newNodeByOldNode = new HashMap<Node, Node>(nodes.size());
        final var resultStream = new ArrayList<NodeRefactorResult>();

        final var standinMap = asNodePairs(config.get("standinNodes"));
        final var skipProps = asStringSet(config.get("skipProperties"));
        final var createNodesInInnerTx =
                Boolean.TRUE.equals(config.getOrDefault("createNodesInNewTransactions", false));

        // clone nodes and populate copy map
        for (final var oldNode : nodes) {

            // standinNodes will NOT be cloned
            if (oldNode == null || standinMap.containsKey(oldNode)) continue;

            final var result = new NodeRefactorResult(oldNode.getId());
            try {
                final Node newNode;
                if (!createNodesInInnerTx) newNode = cloneNode(tx, oldNode, skipProps);
                else newNode = withTransactionAndRebind(db, tx, innerTx -> cloneNode(innerTx, oldNode, skipProps));
                resultStream.add(result.withOther(newNode));
                newNodeByOldNode.put(oldNode, newNode);
            } catch (Exception e) {
                resultStream.add(result.withError(e));
            }
        }

        final Iterator<Relationship> relsIterator;
        // empty or missing rels list means get all rels between nodes
        if (rels == null || rels.isEmpty())
            relsIterator = Cover.coverNodes(nodes).iterator();
        else relsIterator = rels.iterator();

        // clone relationships, will be between cloned nodes and/or standins
        while (relsIterator.hasNext()) {
            final var rel = relsIterator.next();
            if (rel == null) continue;

            Node oldStart = rel.getStartNode();
            Node newStart = standinMap.getOrDefault(oldStart, newNodeByOldNode.get(oldStart));

            Node oldEnd = rel.getEndNode();
            Node newEnd = standinMap.getOrDefault(oldEnd, newNodeByOldNode.get(oldEnd));

            if (newStart != null && newEnd != null) cloneRel(rel, newStart, newEnd, skipProps);
        }

        return resultStream.stream();
    }

    private static Node cloneNode(final Transaction tx, final Node node, final Set<String> skipProps) {
        final var newNode =
                tx.createNode(stream(node.getLabels().spliterator(), false).toArray(Label[]::new));
        try {
            node.getAllProperties().forEach((k, v) -> {
                if (skipProps.isEmpty() || !skipProps.contains(k)) newNode.setProperty(k, v);
            });
        } catch (Exception e) {
            newNode.delete();
            throw e;
        }
        return newNode;
    }

    private static void cloneRel(Relationship base, Node from, Node to, final Set<String> skipProps) {
        final var rel = from.createRelationshipTo(to, base.getType());
        base.getAllProperties().forEach((k, v) -> {
            if (skipProps.isEmpty() || !skipProps.contains(k)) rel.setProperty(k, v);
        });
    }

    private Map<Node, Node> asNodePairs(Object o) {
        if (o == null) return Collections.emptyMap();
        else if (o instanceof List<?> list) {
            return list.stream()
                    .filter(Objects::nonNull)
                    .map(GraphRefactoring::castNodePair)
                    .collect(Collectors.toUnmodifiableMap(l -> l.get(0), l -> l.get(1)));
        } else {
            throw new IllegalArgumentException("Expected a list of node pairs but got " + o);
        }
    }

    private static Set<String> asStringSet(Object o) {
        if (o == null) return Collections.emptySet();
        else if (o instanceof Collection<?> c && c.stream().allMatch(i -> i instanceof String)) {
            return c.stream().map(Object::toString).collect(Collectors.toSet());
        } else throw new IllegalArgumentException("Expected a list of string parameter keys but got " + o);
    }

    private static List<Node> castNodePair(Object o) {
        if (o instanceof List<?> l && l.size() == 2 && l.get(0) instanceof Node && l.get(1) instanceof Node) {
            //noinspection unchecked
            return (List<Node>) l;
        } else {
            throw new IllegalArgumentException("Expected pair of nodes but got " + o);
        }
    }

    public record MergedNodeResult(@Description("The merged node.") Node node) {}

    /**
     * Merges the nodes onto the first node.
     * The other nodes are deleted and their relationships moved onto that first node.
     */
    @Procedure(name = "apoc.refactor.mergeNodes", mode = Mode.WRITE, eager = true)
    @Description("Merges the given `LIST<NODE>` onto the first `NODE` in the `LIST<NODE>`.\n"
            + "All `RELATIONSHIP` values are merged onto that `NODE` as well.")
    public Stream<MergedNodeResult> mergeNodes(
            @Name(value = "nodes", description = "The nodes to be merged onto the first node.") List<Node> nodes,
            @Name(
                            value = "config",
                            defaultValue = "{}",
                            description =
                                    """
                    {
                        mergeRels :: BOOLEAN,
                        selfRef :: BOOLEAN,
                        produceSelfRef = true :: BOOLEAN,
                        preserveExistingSelfRels = true :: BOOLEAN,
                        countMerge = true :: BOOLEAN,
                        collapsedLabel :: BOOLEAN,
                        singleElementAsArray = false :: BOOLEAN,
                        avoidDuplicates = false :: BOOLEAN,
                        relationshipSelectionStrategy = "incoming" :: ["incoming", "outgoing", "merge"]
                        properties :: ["overwrite", ""discard", "combine"]
                    }
                    """)
                    Map<String, Object> config) {
        if (nodes == null || nodes.isEmpty()) return Stream.empty();
        RefactorConfig conf = new RefactorConfig(config);
        Set<Node> nodesSet = new LinkedHashSet<>(nodes);
        // grab write locks upfront consistently ordered
        nodesSet.stream().sorted(Comparator.comparing(Node::getElementId)).forEach(tx::acquireWriteLock);

        final Node first = nodes.get(0);
        final List<String> existingSelfRelIds = conf.isPreservingExistingSelfRels()
                ? stream(first.getRelationships().spliterator(), false)
                        .filter(Util::isSelfRel)
                        .map(Entity::getElementId)
                        .collect(Collectors.toList())
                : Collections.emptyList();

        nodesSet.stream().skip(1).forEach(node -> mergeNodes(node, first, conf, existingSelfRelIds));
        return Stream.of(new MergedNodeResult(first));
    }

    public record MergedRelationshipResult(@Description("The merged relationship.") Relationship rel) {}

    /**
     * Merges the relationships onto the first relationship and delete them.
     * All relationships must have the same starting node and ending node.
     */
    @Procedure(name = "apoc.refactor.mergeRelationships", mode = Mode.WRITE)
    @Description("Merges the given `LIST<RELATIONSHIP>` onto the first `RELATIONSHIP` in the `LIST<RELATIONSHIP>`.")
    public Stream<MergedRelationshipResult> mergeRelationships(
            @Name(value = "rels", description = "The relationships to be merged onto the first relationship.")
                    List<Relationship> relationships,
            @Name(
                            value = "config",
                            defaultValue = "{}",
                            description =
                                    """
                    {
                        mergeRels :: BOOLEAN,
                        selfRef :: BOOLEAN,
                        produceSelfRef = true :: BOOLEAN,
                        preserveExistingSelfRels = true :: BOOLEAN,
                        countMerge = true :: BOOLEAN,
                        collapsedLabel :: BOOLEAN,
                        singleElementAsArray = false :: BOOLEAN,
                        avoidDuplicates = false :: BOOLEAN,
                        relationshipSelectionStrategy = "incoming" :: ["incoming", "outgoing", "merge"]
                        properties :: ["overwrite", "discard", "combine"]
                    }
                    """)
                    Map<String, Object> config) {
        if (relationships == null || relationships.isEmpty()) return Stream.empty();
        Set<Relationship> relationshipsSet = new LinkedHashSet<>(relationships);
        RefactorConfig conf = new RefactorConfig(config);
        Iterator<Relationship> it = relationshipsSet.iterator();
        Relationship first = it.next();
        while (it.hasNext()) {
            Relationship other = it.next();
            if (first.getStartNode().equals(other.getStartNode())
                    && first.getEndNode().equals(other.getEndNode())) mergeRels(other, first, true, conf);
            else throw new RuntimeException("All Relationships must have the same start and end nodes.");
        }
        return Stream.of(new MergedRelationshipResult(first));
    }

    /**
     * Changes the relationship-type of a relationship by creating a new one between the two nodes
     * and deleting the old.
     */
    @Procedure(name = "apoc.refactor.setType", mode = Mode.WRITE)
    @QueryLanguageScope(scope = {QueryLanguage.CYPHER_5})
    @Description("Changes the type of the given `RELATIONSHIP`.")
    public Stream<UpdatedRelationshipResult> setTypeCypher5(
            @Name(value = "rel", description = "The relationship to change the type of.") Relationship rel,
            @Name(value = "newType", description = "The new type for the relationship.") String newType) {
        return setType(rel, newType);
    }

    @Deprecated
    @Procedure(
            name = "apoc.refactor.setType",
            mode = Mode.WRITE,
            deprecatedBy =
                    "Cypher's dynamic types: `CREATE (from)-[newRel:$(newType)]->(to) SET newRel = properties(oldRel) DELETE oldRel`.")
    @QueryLanguageScope(scope = {QueryLanguage.CYPHER_25})
    @Description("Changes the type of the given `RELATIONSHIP`.")
    public Stream<UpdatedRelationshipResult> setType(
            @Name(value = "rel", description = "The relationship to change the type of.") Relationship rel,
            @Name(value = "newType", description = "The new type for the relationship.") String newType) {
        if (rel == null) return Stream.empty();
        UpdatedRelationshipResult result = new UpdatedRelationshipResult(rel.getId());
        try {
            Relationship newRel =
                    rel.getStartNode().createRelationshipTo(rel.getEndNode(), RelationshipType.withName(newType));
            copyProperties(rel, newRel);
            rel.delete();
            return Stream.of(result.withOther(newRel));
        } catch (Exception e) {
            return Stream.of(result.withError(e));
        }
    }

    /**
     * Redirects a relationships to a new target node.
     */
    @Procedure(name = "apoc.refactor.to", mode = Mode.WRITE, eager = true)
    @Description("Redirects the given `RELATIONSHIP` to the given end `NODE`.")
    public Stream<UpdatedRelationshipResult> to(
            @Name(value = "rel", description = "The relationship to redirect.") Relationship rel,
            @Name(value = "endNode", description = "The new end node the relationship should point to.") Node newNode,
            @Name(
                            value = "config",
                            defaultValue = "{}",
                            description =
                                    """
                {
                    failOnErrors = false :: BOOLEAN
                }
                """)
                    Map<String, Object> config) {
        if (config == null) config = Map.of();
        if (rel == null || newNode == null) return Stream.empty();

        final var failOnErrors = Boolean.TRUE.equals(config.getOrDefault("failOnErrors", false));
        final var result = new UpdatedRelationshipResult(rel.getId());

        try {
            final var type = rel.getType();
            final var properties = rel.getAllProperties();
            final var startNode = rel.getStartNode();

            // Delete first to not break constraints.
            rel.delete();

            final var newRel = startNode.createRelationshipTo(newNode, type);
            properties.forEach(newRel::setProperty);

            return Stream.of(result.withOther(newRel));
        } catch (Exception e) {
            if (failOnErrors) {
                throw e;
            } else {
                // Note! We might now have half applied the changes,
                // not sure why we would ever want to do this instead of just failing.
                // I guess it's up to the user to explicitly rollback at this point ¯\_(ツ)_/¯.
                return Stream.of(result.withError(e));
            }
        }
    }

    @Procedure(name = "apoc.refactor.invert", mode = Mode.WRITE, eager = true)
    @Description("Inverts the direction of the given `RELATIONSHIP`.")
    public Stream<RefactorRelationshipResult> invert(
            @Name(value = "rel", description = "The relationship to reverse.") Relationship rel,
            @Name(
                            value = "config",
                            defaultValue = "{}",
                            description =
                                    """
                    {
                        failOnErrors = false :: BOOLEAN
                    }
                    """)
                    Map<String, Object> config) {
        if (config == null) config = Map.of();
        if (rel == null) return Stream.empty();

        final var failOnErrors = Boolean.TRUE.equals(config.getOrDefault("failOnErrors", false));
        final var result = new RefactorRelationshipResult(rel.getId());

        try {
            final var type = rel.getType();
            final var properties = rel.getAllProperties();
            final var startNode = rel.getStartNode();
            final var endNode = rel.getEndNode();

            // Delete first to not break constraints.
            rel.delete();

            final var newRel = endNode.createRelationshipTo(startNode, type);
            properties.forEach(newRel::setProperty);

            return Stream.of(result.withOther(newRel));
        } catch (Exception e) {
            if (failOnErrors) {
                throw e;
            } else {
                // Note! We might now have half applied the changes,
                // not sure why we would ever want to do this instead of just failing.
                // I guess it's up to the user to explicitly rollback at this point ¯\_(ツ)_/¯.
                return Stream.of(result.withError(e));
            }
        }
    }

    /**
     * Redirects a relationships to a new target node.
     */
    @Procedure(name = "apoc.refactor.from", mode = Mode.WRITE, eager = true)
    @Description("Redirects the given `RELATIONSHIP` to the given start `NODE`.")
    public Stream<RefactorRelationshipResult> from(
            @Name(value = "rel", description = "The relationship to redirect.") Relationship rel,
            @Name(value = "newNode", description = "The node to redirect the given relationship to.") Node newNode,
            @Name(
                            value = "config",
                            defaultValue = "{}",
                            description =
                                    """
                    {
                        failOnErrors = false :: BOOLEAN
                    }
                    """)
                    Map<String, Object> config) {
        if (config == null) config = Map.of();
        if (rel == null || newNode == null) return Stream.empty();

        final var result = new RefactorRelationshipResult(rel.getId());
        final var failOnErrors = Boolean.TRUE.equals(config.getOrDefault("failOnErrors", false));

        try {
            final var type = rel.getType();
            final var properties = rel.getAllProperties();
            final var endNode = rel.getEndNode();

            // Delete before setting properties to not break constraints.
            rel.delete();

            final var newRel = newNode.createRelationshipTo(endNode, type);
            properties.forEach(newRel::setProperty);

            return Stream.of(result.withOther(newRel));
        } catch (Exception e) {
            if (failOnErrors) {
                throw e;
            } else {
                // Note! We might now have half applied the changes,
                // not sure why we would ever want to do this instead of just failing.
                // I guess it's up to the user to explicitly rollback at this point ¯\_(ツ)_/¯.
                return Stream.of(result.withError(e));
            }
        }
    }

    /**
     * Make properties boolean
     */
    @Procedure(name = "apoc.refactor.normalizeAsBoolean", mode = Mode.WRITE)
    @Description("Refactors the given property to a `BOOLEAN`.")
    public void normalizeAsBoolean(
            @Name(
                            value = "entity",
                            description = "The node or relationship whose properties will be normalized to booleans.")
                    Object entity,
            @Name(value = "propertyKey", description = "The name of the property key to normalize.") String propertyKey,
            @Name(value = "trueValues", description = "The possible representations of true values.")
                    List<Object> trueValues,
            @Name(value = "falseValues", description = "The possible representations of false values.")
                    List<Object> falseValues) {
        if (entity instanceof Entity pc) {
            Object value = pc.getProperty(propertyKey, null);
            if (value != null) {
                boolean isTrue = trueValues.contains(value);
                boolean isFalse = falseValues.contains(value);
                if (isTrue && !isFalse) {
                    pc.setProperty(propertyKey, true);
                }
                if (!isTrue && isFalse) {
                    pc.setProperty(propertyKey, false);
                }
                if (!isTrue && !isFalse) {
                    pc.removeProperty(propertyKey);
                }
            }
        }
    }

    /**
     * Create category nodes from unique property values
     */
    @Procedure(name = "apoc.refactor.categorize", mode = Mode.WRITE)
    @QueryLanguageScope(scope = {QueryLanguage.CYPHER_5})
    @Description(
            "Creates new category `NODE` values from `NODE` values in the graph with the specified `sourceKey` as one of its property keys.\n"
                    + "The new category `NODE` values are then connected to the original `NODE` values with a `RELATIONSHIP` of the given type.")
    public void categorizeCypher5(
            @Name(value = "sourceKey", description = "The property key to add to the on the new node.")
                    String sourceKey,
            @Name(value = "type", description = "The relationship type to connect to the new node.")
                    String relationshipType,
