package com.thinkbiganalytics.nifi.rest.client.layout;

/*-
 * #%L
 * thinkbig-nifi-rest-client-api
 * %%
 * Copyright (C) 2017 ThinkBig Analytics
 * %%
 * 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.
 * #L%
 */

import com.thinkbiganalytics.nifi.rest.client.NiFiRestClient;

import org.apache.nifi.web.api.dto.ConnectionDTO;
import org.apache.nifi.web.api.dto.PortDTO;
import org.apache.nifi.web.api.dto.PositionDTO;
import org.apache.nifi.web.api.dto.ProcessGroupDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * Align Nifi Components under a supplied ProcessGroupId
 */
public class AlignProcessGroupComponents {

    private static final Logger log = LoggerFactory.getLogger(AlignProcessGroupComponents.class);


    NiFiRestClient niFiRestClient;

    /**
     * Map storing the various LayoutGroups computed for the ProcessGroups within the supplied {@code parentProcessGroupId}
     */
    Map<String, LayoutGroup> layoutGroups = new HashMap<>();
    /**
     * The ProcessGroup to inspect
     */
    private String parentProcessGroupId;
    /**
     * Internal counter as to the number of {@code LayoutGroup}s created
     */
    private Integer groupNumber = 0;
    /**
     * Configuration as to the height, padding for the various components
     */
    private AlignComponentsConfig alignmentConfig;

    /**
     * The Group matching the supplied {@code parentProcessGroupId}
     */
    private ProcessGroupDTO parentProcessGroup;

    /**
     * map of all the groups under the supplied parent
     */
    private Map<String, ProcessGroupDTO> processGroupDTOMap;

    /**
     * map of all the outputs under the supplied parent
     */
    private Map<String, PortDTO> outputPortMap = new HashMap<>();

    /**
     * map of all the inputs under the supplied parent
     */
    private Map<String, PortDTO> inputPortMap = new HashMap<>();


    /**
     * Map of the processGroupId to object with connections
     */
    private Map<String, ProcessGroupAndConnections> processGroupWithConnectionsMap = new HashMap<>();


    /**
     * Flag to indicate when Alignment is complete
     */
    private boolean aligned = false;

    /**
     * Pointer to the last Positioned LayoutGroup
     */
    private LayoutGroup lastPositionedGroup;

    public AlignProcessGroupComponents(NiFiRestClient niFiRestClient, String parentProcessGroupId, AlignComponentsConfig alignmentConfig) {
        this.niFiRestClient = niFiRestClient;
        this.parentProcessGroupId = parentProcessGroupId;
        this.alignmentConfig = alignmentConfig;
    }

    public AlignProcessGroupComponents(NiFiRestClient niFiRestClient, String parentProcessGroupId) {
        this(niFiRestClient, parentProcessGroupId, new AlignComponentsConfig());
    }

    /**
     * For the passed in {@code parentProcessGroupId} it will group the items and then apply different
     */
    public ProcessGroupDTO autoLayout() {
        try {
            groupItems();
            //organize each group of items on the screen
            layoutGroups.entrySet().stream().sorted(Map.Entry.<String, LayoutGroup>comparingByKey()).forEachOrdered(entry -> arrangeProcessGroup(entry.getValue()));
            aligned = true;
        } catch (Exception e) {
            log.error("Error Aligning items in Process Group {}. {}", parentProcessGroupId, e.getMessage());
        }
        return parentProcessGroup;
    }


    /**
     * Group the items together into different {@code LayoutGroup} based upon connections from the ProcessGroup to its various ports.
     */
    public Map<String, LayoutGroup> groupItems() {
        layoutGroups = new HashMap<>();
        //find the parent and children
        if (parentProcessGroupId == "root") {
            parentProcessGroup = niFiRestClient.processGroups().findRoot();
        } else {
            parentProcessGroup = niFiRestClient.processGroups().findById(parentProcessGroupId, false, true).orElse(null);
        }
        final Set<ProcessGroupDTO> children = parentProcessGroup.getContents().getProcessGroups();
        processGroupDTOMap = new HashMap<>();
        children.stream().forEach(group -> processGroupDTOMap.put(group.getId(), group));
        children.stream().forEach(group -> processGroupWithConnectionsMap.put(group.getId(), new ProcessGroupAndConnections(group)));

        parentProcessGroup.getContents().getOutputPorts().stream().forEach(portDTO -> outputPortMap.put(portDTO.getId(), portDTO));

        parentProcessGroup.getContents().getInputPorts().stream().forEach(portDTO -> inputPortMap.put(portDTO.getId(), portDTO));

        //map any ports to processgroups
        //group the items by their respective output ports
        createLayoutGroups();
        return layoutGroups;

    }

    /**
     * Based upon the LayoutGroup apply the correct Rendering technique to layout the components
     */
    private void arrangeProcessGroup(LayoutGroup layoutGroup) {

        log.info("Arrange Group {}", layoutGroup.getClass().getSimpleName());
        //set the starting Y coords for this group
        Double start = lastPositionedGroup == null ? 0.0d : lastPositionedGroup.getBottomY() + alignmentConfig.getGroupPadding();
        layoutGroup.setTopAndBottom(start, new Double(layoutGroup.getHeight() + start));
        layoutGroup.setGroupNumber(groupNumber++);

        if (layoutGroup instanceof InputPortToProcessGroup) {
            arrangeInputPortToProcessGroupLayout((InputPortToProcessGroup) layoutGroup);
        } else if (layoutGroup instanceof ProcessGroupToOutputPort) {
            arrangeProcessGroupToOutputPortLayout((ProcessGroupToOutputPort) layoutGroup);
        } else if (layoutGroup instanceof ProcessGroupToProcessGroup) {
            arrangeProcessGroupLayout((ProcessGroupToProcessGroup) layoutGroup);
        } else if (layoutGroup instanceof ProcessGroupWithoutConnections) {
            arrangeProcessGroupWithoutConnectionsLayout((ProcessGroupWithoutConnections) layoutGroup);
        }

        lastPositionedGroup = layoutGroup;

    }


    public Map<String, ProcessGroupAndConnections> getProcessGroupWithConnectionsMap() {
        return processGroupWithConnectionsMap;
    }

    public boolean isAligned() {
        return aligned;
    }

    private void arrangeProcessGroupWithoutConnectionsLayout(ProcessGroupWithoutConnections layout) {
        defaultProcessGroupLayoutArrangement(layout);
    }

    /**
     * Arrange top and bottom
     */
    private void arrangeProcessGroupLayout(ProcessGroupToProcessGroup layout) {

        //ClockRenderer clock = new ClockRenderer(layoutGroups,alignmentConfig);
        //arrange the dest in the center
        TopBottomRowsRenderer topBottomRowsRenderer = new TopBottomRowsRenderer(layout, alignmentConfig);

        SingleRowRenderer rowRenderer = new SingleRowRenderer(layout, alignmentConfig, layout.getMiddleY());
        alignProcessGroups(layout.getDestinations(), rowRenderer);

        alignProcessGroups(layout.getProcessGroupDTOs(), topBottomRowsRenderer);

    }

    private void arrangeProcessGroupToOutputPortLayout(ProcessGroupToOutputPort layout) {

        if (!layout.getPorts().isEmpty()) {
            Integer outputPortCount = layout.getPorts().size();

            ColumnRenderer columnRenderer = new ColumnRenderer(layout, alignmentConfig, (alignmentConfig.getCenterX() - (alignmentConfig.getPortWidth() / 2)), outputPortCount);
            columnRenderer
                .updateHeight((columnRenderer.getItemCount() * alignmentConfig.getPortHeight() + alignmentConfig.getProcessGroupHeight() + (2 * alignmentConfig.getProcessGroupPaddingTopBottom())));
            alignOutputPorts(layout, columnRenderer);
        }
        SingleRowRenderer rowRenderer = new SingleRowRenderer(layout, alignmentConfig, layout.getMiddleY(alignmentConfig.getProcessGroupHeight() / 2));
        alignProcessGroups(layout.getProcessGroupDTOs(), rowRenderer);

    }

    private void arrangeInputPortToProcessGroupLayout(InputPortToProcessGroup layout) {

        if (!layout.getPorts().isEmpty()) {
            Integer outputPortCount = layout.getPorts().size();

            ColumnRenderer columnRenderer = new ColumnRenderer(layout, alignmentConfig, (alignmentConfig.getCenterX() - (alignmentConfig.getPortWidth() / 2)), outputPortCount);
            columnRenderer
                .updateHeight((columnRenderer.getItemCount() * alignmentConfig.getPortHeight() + alignmentConfig.getProcessGroupHeight() + (2 * alignmentConfig.getProcessGroupPaddingTopBottom())));
            alignInputPorts(layout, columnRenderer);
        }
        SingleRowRenderer rowRenderer = new SingleRowRenderer(layout, alignmentConfig, layout.getMiddleY(alignmentConfig.getProcessGroupHeight() / 2));
        alignProcessGroups(layout.getProcessGroupDTOs(), rowRenderer);

    }


    private void alignOutputPorts(ProcessGroupToOutputPort layoutGroup, AbstractRenderer renderer) {
        layoutGroup.getPorts().values().stream().forEach(port -> {
            PortDTO positionPort = new PortDTO();
            positionPort.setId(port.getId());
            PositionDTO lastPosition = renderer.getLastPosition();
            PositionDTO newPosition = renderer.getNextPosition(lastPosition);
            positionPort.setPosition(newPosition);
            niFiRestClient.ports().updateOutputPort(parentProcessGroupId, positionPort);
            log.info("Aligned Port {} at {},{}", port.getName(), positionPort.getPosition().getX(), positionPort.getPosition().getY());
        });
    }

    private void alignInputPorts(InputPortToProcessGroup layoutGroup, AbstractRenderer renderer) {
        layoutGroup.getPorts().values().stream().forEach(port -> {
            PortDTO positionPort = new PortDTO();
            positionPort.setId(port.getId());
            PositionDTO lastPosition = renderer.getLastPosition();
            PositionDTO newPosition = renderer.getNextPosition(lastPosition);
            positionPort.setPosition(newPosition);
            niFiRestClient.ports().updateInputPort(parentProcessGroupId, positionPort);
            log.info("Aligned Port {} at {},{}", port.getName(), positionPort.getPosition().getX(), positionPort.getPosition().getY());
        });
    }


    private void alignProcessGroups(Set<ProcessGroupDTO> processGroups, AbstractRenderer renderer) {
        processGroups.stream().forEach(processGroupDTO -> {
            ProcessGroupDTO positionProcessGroup = new ProcessGroupDTO();
            positionProcessGroup.setId(processGroupDTO.getId());
            PositionDTO lastPosition = renderer.getLastPosition();
            PositionDTO newPosition = renderer.getNextPosition(lastPosition);
            positionProcessGroup.setPosition(newPosition);
            niFiRestClient.processGroups().update(positionProcessGroup);
            log.info("Aligned ProcessGroup {} at {},{}", processGroupDTO.getName(), positionProcessGroup.getPosition().getX(), positionProcessGroup.getPosition().getY());
        });
    }


    private void defaultProcessGroupLayoutArrangement(LayoutGroup layoutGroup) {
        SingleRowRenderer rowRenderer = new SingleRowRenderer(layoutGroup, alignmentConfig, layoutGroup.getMiddleY(alignmentConfig.getProcessGroupHeight() / 2));
        alignProcessGroups(layoutGroup.getProcessGroupDTOs(), rowRenderer);
    }


    private boolean isGroupToGroupConnection(ConnectionDTO connectionDTO) {
        return (processGroupDTOMap.containsKey(connectionDTO.getDestination().getGroupId()) && processGroupDTOMap.containsKey(connectionDTO.getSource().getGroupId()));
    }

    private boolean isOutputPortToGroupConnection(ConnectionDTO connectionDTO) {
        return (outputPortMap.containsKey(connectionDTO.getDestination().getId()) || outputPortMap.containsKey(connectionDTO.getSource().getId()))
               && (processGroupDTOMap.containsKey(connectionDTO.getDestination().getGroupId()) || processGroupDTOMap.containsKey(connectionDTO.getSource().getGroupId()));
    }

    private boolean isInputPortToGroupConnection(ConnectionDTO connectionDTO) {
        return (inputPortMap.containsKey(connectionDTO.getDestination().getId()) || inputPortMap.containsKey(connectionDTO.getSource().getId()))
               && (processGroupDTOMap.containsKey(connectionDTO.getDestination().getGroupId()) || processGroupDTOMap.containsKey(connectionDTO.getSource().getGroupId()));
    }

    /**
     * Group the items together to create the various LayoutGroups needed for different Rendering
     */
    private void createLayoutGroups() {
        Map<String, Set<ProcessGroupDTO>> outputPortIdToGroup = new HashMap<String, Set<ProcessGroupDTO>>();
        Map<String, Set<PortDTO>> groupIdToOutputPorts = new HashMap<>();

        Map<String, Set<ProcessGroupDTO>> inputPortIdToGroup = new HashMap<String, Set<ProcessGroupDTO>>();
        Map<String, Set<PortDTO>> groupIdToInputPorts = new HashMap<>();

        Map<String, Set<String>> groupIdToGroup = new HashMap<>();

        parentProcessGroup.getContents().getConnections().stream().filter(
            connectionDTO -> (isOutputPortToGroupConnection(connectionDTO) || isGroupToGroupConnection(connectionDTO) || isInputPortToGroupConnection(connectionDTO)))
            .forEach(connectionDTO -> {
                PortDTO
                    outputPort =
                    outputPortMap.get(connectionDTO.getDestination().getId()) == null ? outputPortMap.get(connectionDTO.getSource().getId())
                                                                                      : outputPortMap.get(connectionDTO.getDestination().getId());

                PortDTO
                    inputPort =
                    inputPortMap.get(connectionDTO.getSource().getId()) == null ? inputPortMap.get(connectionDTO.getDestination().getId())
                                                                                : inputPortMap.get(connectionDTO.getSource().getId());

                ProcessGroupDTO
                    destinationGroup =
                    processGroupDTOMap.get(connectionDTO.getDestination().getGroupId());

                ProcessGroupDTO
                    sourceGroup =
                    processGroupDTOMap.get(connectionDTO.getSource().getGroupId());

                if (outputPort != null) {
                    ProcessGroupDTO processGroup = destinationGroup == null ? sourceGroup : destinationGroup;
                    outputPortIdToGroup.computeIfAbsent(outputPort.getId(), (key) -> new HashSet<ProcessGroupDTO>()).add(processGroup);
                    groupIdToOutputPorts.computeIfAbsent(processGroup.getId(), (key) -> new HashSet<PortDTO>()).add(outputPort);
                    if (processGroupWithConnectionsMap.containsKey(processGroup.getId())) {
                        processGroupWithConnectionsMap.get(processGroup.getId()).addConnection(connectionDTO).addPort(outputPort);
                    }
                }
                if (inputPort != null) {
                    ProcessGroupDTO processGroup = destinationGroup == null ? sourceGroup : destinationGroup;
                    inputPortIdToGroup.computeIfAbsent(inputPort.getId(), (key) -> new HashSet<ProcessGroupDTO>()).add(processGroup);
                    groupIdToInputPorts.computeIfAbsent(processGroup.getId(), (key) -> new HashSet<PortDTO>()).add(inputPort);
                    if (processGroupWithConnectionsMap.containsKey(processGroup.getId())) {
                        processGroupWithConnectionsMap.get(processGroup.getId()).addConnection(connectionDTO).addPort(outputPort);
                    }
                } else if (destinationGroup != null && sourceGroup != null) {
                    groupIdToGroup.computeIfAbsent(sourceGroup.getId(), (key) -> new HashSet<String>()).add(destinationGroup.getId());
                }

            });

        // group port connections together
        groupIdToOutputPorts.entrySet().stream().forEach(entry -> {
            String processGroupId = entry.getKey();
            String portKey = entry.getValue().stream().map(portDTO -> portDTO.getId()).sorted().collect(Collectors.joining(","));
            portKey = "AAA" + portKey;
            layoutGroups.computeIfAbsent(portKey, (key) -> new ProcessGroupToOutputPort(entry.getValue())).add(processGroupDTOMap.get(processGroupId));
        });

        // group port connections together
        groupIdToInputPorts.entrySet().stream().forEach(entry -> {
            String processGroupId = entry.getKey();
            String portKey = entry.getValue().stream().map(portDTO -> portDTO.getId()).sorted().collect(Collectors.joining(","));
            portKey = "BBB" + portKey;
            layoutGroups.computeIfAbsent(portKey, (key) -> new InputPortToProcessGroup(entry.getValue())).add(processGroupDTOMap.get(processGroupId));
        });

        groupIdToGroup.entrySet().stream().forEach(entry -> {
            String sourceGroupId = entry.getKey();
            String processGroupKey = entry.getValue().stream().sorted().collect(Collectors.joining(","));
            processGroupKey = "CCC" + processGroupKey;

            layoutGroups.computeIfAbsent(processGroupKey, (key) -> new ProcessGroupToProcessGroup(entry.getValue())).add(processGroupDTOMap.get(entry.getKey()));
        });

        //add in any groups that dont have connections to ports
        processGroupDTOMap.values().stream().filter(
            processGroupDTO -> !groupIdToGroup.values().stream().flatMap(set -> set.stream()).collect(Collectors.toSet()).contains(processGroupDTO.getId()) && !groupIdToInputPorts
                .containsKey(processGroupDTO.getId()) && !groupIdToOutputPorts.containsKey(processGroupDTO.getId()) && !groupIdToGroup.containsKey(processGroupDTO.getId()))
            .forEach(group -> {
                layoutGroups.computeIfAbsent("NO_PORTS", (key) -> new ProcessGroupWithoutConnections()).add(group);
            });

    }


    public void setNiFiRestClient(NiFiRestClient niFiRestClient) {
        this.niFiRestClient = niFiRestClient;
    }


    /**
     * Layout where a ProcessGroup has no Connections
     */
    public class ProcessGroupWithoutConnections extends LayoutGroup {

        public ProcessGroupWithoutConnections() {

        }

        public Integer calculateHeight() {
            return (alignmentConfig.getProcessGroupHeight()) + (alignmentConfig.getProcessGroupPaddingTopBottom());
        }

    }

    /**
     * Layout Group where a ProcessGroup is connected directly to another ProcessGroup
     */
    public class ProcessGroupToProcessGroup extends LayoutGroup {

        private Set<ProcessGroupDTO> destinations = new HashSet<>();


        public ProcessGroupToProcessGroup(Set<String> destinations) {
            this.destinations = destinations.stream().map(groupId -> processGroupDTOMap.get(groupId)).collect(Collectors.toSet());
        }


        public Set<ProcessGroupDTO> getSources() {
            return super.getProcessGroupDTOs();
        }

        @Override
        public Integer calculateHeight() {
            return alignmentConfig.getProcessGroupHeight() + alignmentConfig.getProcessGroupPaddingTopBottom();
        }

        public Set<ProcessGroupDTO> getDestinations() {
            return destinations;
        }
    }


    /**
     * Layout Group where an Input Port is connected to a ProcessGroup
     */
    public class InputPortToProcessGroup extends ProcessGroupToOutputPort {

        public InputPortToProcessGroup(Set<PortDTO> inputPorts) {
            super(inputPorts);
        }
    }

    /**
     * Group where a ProcessGroup is connected to an OutputPort
     */
    public class ProcessGroupToOutputPort extends ProcessGroupToPort {

        public ProcessGroupToOutputPort(Set<PortDTO> outputPorts) {
            super(outputPorts);
        }
    }

    /**
     * LayoutGroup where a Port is connected to a Process Group
     */
    public abstract class ProcessGroupToPort extends LayoutGroup {

        private Map<String, PortDTO> ports = new HashMap<>();


        public ProcessGroupToPort(Set<PortDTO> ports) {
            if (ports != null) {
                ports.stream().forEach(portDTO -> {
                    this.ports.put(portDTO.getId(), portDTO);
                });
            }
        }


        public Integer calculateHeight() {
            Integer portCount = ports.size();
            return (alignmentConfig.getPortHeight() * portCount) + alignmentConfig.getProcessGroupHeight() + (alignmentConfig.getProcessGroupPaddingTopBottom() * portCount);
        }

        public Double getMiddleY() {
            return super.getMiddleY();
        }


        public Map<String, PortDTO> getPorts() {
            return ports;
        }


    }


}
