/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.nifi.controller;

import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.apache.nifi.authorization.Resource;
import org.apache.nifi.authorization.resource.Authorizable;
import org.apache.nifi.authorization.resource.ResourceFactory;
import org.apache.nifi.authorization.resource.ResourceType;
import org.apache.nifi.components.ValidationResult;
import org.apache.nifi.connectable.Connectable;
import org.apache.nifi.connectable.ConnectableType;
import org.apache.nifi.connectable.Connection;
import org.apache.nifi.connectable.Funnel;
import org.apache.nifi.connectable.Position;
import org.apache.nifi.flowfile.FlowFile;
import org.apache.nifi.groups.ProcessGroup;
import org.apache.nifi.processor.ProcessContext;
import org.apache.nifi.processor.ProcessSession;
import org.apache.nifi.processor.ProcessSessionFactory;
import org.apache.nifi.processor.Relationship;
import org.apache.nifi.processor.exception.ProcessException;
import org.apache.nifi.scheduling.SchedulingStrategy;
import org.apache.nifi.util.FormatUtils;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

import static java.util.Objects.requireNonNull;

public class StandardFunnel implements Funnel {

    public static final TimeUnit DEFAULT_TIME_UNIT = TimeUnit.MILLISECONDS;
    private static final String DEFAULT_MAX_BACKOFF_PERIOD = "10 mins";

    private final String identifier;
    private final Set<Connection> outgoingConnections;
    private final List<Connection> incomingConnections;
    private final List<Relationship> relationships;

    private final AtomicReference<ProcessGroup> processGroupRef;
    private final AtomicReference<Position> position;
    private final AtomicReference<String> penalizationPeriod;
    private final AtomicReference<String> yieldPeriod;
    private final AtomicReference<String> schedulingPeriod;
    private final AtomicReference<String> name;
    private final AtomicLong schedulingNanos;
    private final AtomicBoolean lossTolerant;
    private final AtomicReference<ScheduledState> scheduledState;
    private final AtomicLong yieldExpiration;
    private final AtomicReference<String> versionedComponentId = new AtomicReference<>();

    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();

    final int maxIterations;
    private final int maxConcurrentTasks;

    public StandardFunnel(final String identifier, final int maxConcurrentTasks, final int maxBatchSize) {
        this.identifier = identifier;
        this.processGroupRef = new AtomicReference<>();

        outgoingConnections = new HashSet<>();
        incomingConnections = new ArrayList<>();

        final List<Relationship> relationships = new ArrayList<>();
        relationships.add(Relationship.ANONYMOUS);
        this.relationships = Collections.unmodifiableList(relationships);

        lossTolerant = new AtomicBoolean(false);
        position = new AtomicReference<>(new Position(0D, 0D));
        scheduledState = new AtomicReference<>(ScheduledState.STOPPED);
        penalizationPeriod = new AtomicReference<>("30 sec");
        yieldPeriod = new AtomicReference<>("250 millis");
        yieldExpiration = new AtomicLong(0L);
        schedulingPeriod = new AtomicReference<>("0 millis");
        schedulingNanos = new AtomicLong(MINIMUM_SCHEDULING_NANOS);
        name = new AtomicReference<>("Funnel");

        this.maxConcurrentTasks = maxConcurrentTasks;
        this.maxIterations = Math.max(1, (int) Math.ceil(maxBatchSize / 1000.0));
    }

    @Override
    public String getIdentifier() {
        return identifier;
    }

    @Override
    public String getProcessGroupIdentifier() {
        final ProcessGroup procGroup = getProcessGroup();
        return procGroup == null ? null : procGroup.getIdentifier();
    }

    @Override
    public Collection<Relationship> getRelationships() {
        return relationships;
    }

    @Override
    public Relationship getRelationship(final String relationshipName) {
        return (Relationship.ANONYMOUS.getName().equals(relationshipName)) ? Relationship.ANONYMOUS : null;
    }

    @Override
    public Authorizable getParentAuthorizable() {
        return getProcessGroup();
    }

    @Override
    public Resource getResource() {
        return ResourceFactory.getComponentResource(ResourceType.Funnel, getIdentifier(), getName());
    }

    @Override
    public void addConnection(final Connection connection) throws IllegalArgumentException {
        writeLock.lock();
        try {
            if (!requireNonNull(connection).getSource().equals(this) && !connection.getDestination().equals(this)) {
                throw new IllegalArgumentException("Cannot add a connection to a Funnel for which the Funnel is neither the Source nor the Destination");
            }
            if (connection.getSource().equals(this) && connection.getDestination().equals(this)) {
                throw new IllegalArgumentException("Cannot add a connection from a Funnel back to itself");
            }

            if (connection.getDestination().equals(this)) {
                // don't add the connection twice. This may occur if we have a self-loop because we will be told
                // to add the connection once because we are the source and again because we are the destination.
                if (!incomingConnections.contains(connection)) {
                    incomingConnections.add(connection);
                }
            }

            if (connection.getSource().equals(this)) {
                // don't add the connection twice. This may occur if we have a self-loop because we will be told
                // to add the connection once because we are the source and again because we are the destination.
                if (!outgoingConnections.contains(connection)) {
                    for (final Relationship relationship : connection.getRelationships()) {
                        if (!relationship.equals(Relationship.ANONYMOUS)) {
                            throw new IllegalArgumentException("No relationship with name " + relationship + " exists for Funnels");
                        }
                    }

                    outgoingConnections.add(connection);
                }
            }
        } finally {
            writeLock.unlock();
        }
    }

    @Override
    public boolean hasIncomingConnection() {
        readLock.lock();
        try {
            return !incomingConnections.isEmpty();
        } finally {
            readLock.unlock();
        }
    }

    @Override
    public void updateConnection(final Connection connection) throws IllegalStateException {
        if (requireNonNull(connection).getSource().equals(this)) {
            writeLock.lock();
            try {
                if (!outgoingConnections.remove(connection)) {
                    throw new IllegalStateException("No Connection with ID " + connection.getIdentifier() + " is currently registered with this Funnel");
                }
                outgoingConnections.add(connection);
            } finally {
                writeLock.unlock();
            }
        }

        if (connection.getDestination().equals(this)) {
            writeLock.lock();
            try {
                if (!incomingConnections.remove(connection)) {
                    throw new IllegalStateException("No Connection with ID " + connection.getIdentifier() + " is currently registered with this Funnel");
                }
                incomingConnections.add(connection);
            } finally {
                writeLock.unlock();
            }
        }
    }

    @Override
    public void removeConnection(final Connection connection) throws IllegalArgumentException, IllegalStateException {
        writeLock.lock();
        try {
            if (!requireNonNull(connection).getSource().equals(this)) {
                final boolean existed = incomingConnections.remove(connection);
                if (!existed) {
                    throw new IllegalStateException("The given connection is not currently registered for this Funnel");
                }
                return;
            }

            final boolean removed = outgoingConnections.remove(connection);
            if (!removed) {
                throw new IllegalStateException(connection.getIdentifier() + " is not registered with " + this.getIdentifier());
            }
        } finally {
            writeLock.unlock();
        }
    }

    @Override
    public Set<Connection> getConnections() {
        readLock.lock();
        try {
            return Collections.unmodifiableSet(outgoingConnections);
        } finally {
            readLock.unlock();
        }
    }

    @Override
    public Set<Connection> getConnections(final Relationship relationship) {
        readLock.lock();
        try {
            if (relationship.equals(Relationship.ANONYMOUS)) {
                return Collections.unmodifiableSet(outgoingConnections);
            }

            throw new IllegalArgumentException("No relationship with name " + relationship.getName() + " exists for Funnels");
        } finally {
            readLock.unlock();
        }
    }

    @Override
    public List<Connection> getIncomingConnections() {
        readLock.lock();
        try {
            return new ArrayList<>(incomingConnections);
        } finally {
            readLock.unlock();
        }
    }

    @Override
    public Position getPosition() {
        return position.get();
    }

    @Override
    public void setPosition(Position position) {
        this.position.set(position);
    }

    @Override
    public String getName() {
        return name.get();
    }

    /**
     * Throws {@link UnsupportedOperationException}
     *
     * @param name new name
     */
    @Override
    public void setName(final String name) {
        throw new UnsupportedOperationException();
    }

    @Override
    public String getComments() {
        return "";
    }

    @Override
    public void setComments(final String comments) {
        throw new UnsupportedOperationException();
    }

    @Override
    public ProcessGroup getProcessGroup() {
        return processGroupRef.get();
    }

    @Override
    public void setProcessGroup(final ProcessGroup group) {
        processGroupRef.set(group);
    }

    @Override
    public boolean isAutoTerminated(Relationship relationship) {
        return false;
    }

    @Override
    public boolean isRunning() {
        return isRunning(this);
    }

    private boolean isRunning(final Connectable source) {
        return getScheduledState() == ScheduledState.RUNNING;
    }

    @Override
    public boolean isTriggerWhenEmpty() {
        return false;
    }

    @Override
    public ScheduledState getScheduledState() {
        return scheduledState.get();
    }

    @Override
    public boolean isLossTolerant() {
        return lossTolerant.get();
    }

    @Override
    public void setLossTolerant(final boolean lossTolerant) {
        this.lossTolerant.set(lossTolerant);
    }

    @Override
    public String toString() {
        return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE).append("id", getIdentifier()).toString();
    }

    @Override
    public void onTrigger(final ProcessContext context, final ProcessSessionFactory sessionFactory) throws ProcessException {
        final ProcessSession session = sessionFactory.createSession();

        try {
            onTrigger(context, session);
            session.commitAsync();
        } catch (final ProcessException e) {
            session.rollback();
            throw e;
        } catch (final Throwable t) {
            session.rollback();
            throw new RuntimeException(t);
        }
    }

    private void onTrigger(final ProcessContext context, final ProcessSession session) {
        readLock.lock();
        try {
            Set<Relationship> available = context.getAvailableRelationships();
            int iterations = 0;
            while (!available.isEmpty()) {
                final List<FlowFile> flowFiles = session.get(1000);
                if (flowFiles.isEmpty()) {
                    break;
                }

                session.transfer(flowFiles, Relationship.ANONYMOUS);
                session.commitAsync();

                // If there are fewer than 1,000 FlowFiles available to transfer, or if we
                // have hit the configured FlowFile cap, we want to stop. This prevents us from
                // holding the Timer-Driven Thread for an excessive amount of time.
                if (flowFiles.size() < 1000 || ++iterations >= maxIterations) {
                    break;
                }

                available = context.getAvailableRelationships();
            }
        } finally {
            readLock.unlock();
        }
    }

    /**
     * Has no effect
     */
    @Override
    public void setMaxConcurrentTasks(int taskCount) {
    }

    @Override
    public int getMaxConcurrentTasks() {
        return maxConcurrentTasks;
    }

    @Override
    public void setScheduledState(final ScheduledState scheduledState) {
        this.scheduledState.set(scheduledState);
    }

    @Override
    public ConnectableType getConnectableType() {
        return ConnectableType.FUNNEL;
    }

    @Override
    @SuppressWarnings("unchecked")
    public Collection<ValidationResult> getValidationErrors() {
        return Collections.EMPTY_LIST;
    }

    /**
     * Updates the amount of time that this processor should avoid being
     * scheduled when the processor calls
     * {@link org.apache.nifi.processor.ProcessContext#yield() ProcessContext.yield()}
     *
     * @param yieldPeriod new period
     */
    @Override
    public void setYieldPeriod(final String yieldPeriod) {
        final long yieldMillis = FormatUtils.getTimeDuration(requireNonNull(yieldPeriod), TimeUnit.MILLISECONDS);
        if (yieldMillis < 0) {
            throw new IllegalArgumentException("Yield duration must be positive");
        }
        this.yieldPeriod.set(yieldPeriod);
    }

    @Override
    public void setSchedulingPeriod(final String schedulingPeriod) {
        final long schedulingNanos = FormatUtils.getTimeDuration(requireNonNull(schedulingPeriod), TimeUnit.NANOSECONDS);
        if (schedulingNanos < 0) {
            throw new IllegalArgumentException("Scheduling Period must be positive");
        }

        this.schedulingPeriod.set(schedulingPeriod);
        this.schedulingNanos.set(Math.max(MINIMUM_SCHEDULING_NANOS, schedulingNanos));
    }

    @Override
    public long getPenalizationPeriod(final TimeUnit timeUnit) {
        return FormatUtils.getTimeDuration(getPenalizationPeriod(), timeUnit == null ? DEFAULT_TIME_UNIT : timeUnit);
    }

    @Override
    public String getPenalizationPeriod() {
        return penalizationPeriod.get();
    }

    /**
     * Causes the processor not to be scheduled for some period of time. This
     * duration can be obtained and set via the
     * {@link #getYieldPeriod(TimeUnit)} and
     * {@link #setYieldPeriod(String)} methods.
     */
    @Override
    public void yield() {
        final long yieldMillis = getYieldPeriod(TimeUnit.MILLISECONDS);
        yield(yieldMillis, TimeUnit.MILLISECONDS);
    }

    @Override
    public void yield(final long yieldDuration, final TimeUnit timeUnit) {
        final long yieldMillis = timeUnit.toMillis(yieldDuration);
        yieldExpiration.set(Math.max(yieldExpiration.get(), System.currentTimeMillis() + yieldMillis));
    }

    @Override
    public long getYieldExpiration() {
        return yieldExpiration.get();
    }

    @Override
    public String getSchedulingPeriod() {
        return schedulingPeriod.get();
    }

    @Override
    public void setPenalizationPeriod(final String penalizationPeriod) {
        this.penalizationPeriod.set(penalizationPeriod);
    }

    @Override
    public String getYieldPeriod() {
        return yieldPeriod.get();
    }

    @Override
    public long getYieldPeriod(final TimeUnit timeUnit) {
        return FormatUtils.getTimeDuration(getYieldPeriod(), timeUnit == null ? DEFAULT_TIME_UNIT : timeUnit);
    }

    @Override
    public long getSchedulingPeriod(final TimeUnit timeUnit) {
        return timeUnit.convert(schedulingNanos.get(), TimeUnit.NANOSECONDS);
    }

    @Override
    public boolean isSideEffectFree() {
        return true;
    }

    @Override
    public void verifyCanDelete(boolean ignoreConnections) throws IllegalStateException {
        if (ignoreConnections) {
            return;
        }

        readLock.lock();
        try {
            for (final Connection connection : outgoingConnections) {
                connection.verifyCanDelete();
            }

            for (final Connection connection : incomingConnections) {
                if (connection.getSource().equals(this)) {
                    connection.verifyCanDelete();
                } else {
                    throw new IllegalStateException("Funnel " + this.getIdentifier() + " is the destination of another component");
                }
            }
        } finally {
            readLock.unlock();
        }
    }

    @Override
    public void verifyCanDelete() {
        verifyCanDelete(false);
    }

    @Override
    public void verifyCanStart() {
    }

    @Override
    public void verifyCanStop() {
    }

    @Override
    public void verifyCanUpdate() {
    }

    @Override
    public void verifyCanEnable() {
    }

    @Override
    public void verifyCanDisable() {
    }

    @Override
    public void verifyCanClearState() {
    }

    @Override
    public SchedulingStrategy getSchedulingStrategy() {
        return SchedulingStrategy.TIMER_DRIVEN;
    }

    @Override
    public String getComponentType() {
        return "Funnel";
    }

    @Override
    public int getRetryCount() {
        return 0;
    }

    @Override
    public void setRetryCount(Integer retryCount) {
    }

    @Override
    public Set<String> getRetriedRelationships() {
        return Collections.EMPTY_SET;
    }

    @Override
    public void setRetriedRelationships(Set<String> retriedRelationships) {
    }

    @Override
    public boolean isRelationshipRetried(Relationship relationship) {
        return false;
    }

    @Override
    public BackoffMechanism getBackoffMechanism() {
        return BackoffMechanism.PENALIZE_FLOWFILE;
    }

    @Override
    public void setBackoffMechanism(BackoffMechanism backoffMechanism) {
    }

    @Override
    public String getMaxBackoffPeriod() {
        return DEFAULT_MAX_BACKOFF_PERIOD;
    }

    @Override
    public void setMaxBackoffPeriod(String maxBackoffPeriod) {
    }

    @Override
    public String evaluateParameters(String value) {
        return value;
    }

    @Override
    public Optional<String> getVersionedComponentId() {
        return Optional.ofNullable(versionedComponentId.get());
    }

    @Override
    public void setVersionedComponentId(final String versionedComponentId) {
        boolean updated = false;
        while (!updated) {
            final String currentId = this.versionedComponentId.get();

            if (currentId == null) {
                updated = this.versionedComponentId.compareAndSet(null, versionedComponentId);
            } else if (currentId.equals(versionedComponentId)) {
                return;
            } else if (versionedComponentId == null) {
                updated = this.versionedComponentId.compareAndSet(currentId, null);
            } else {
                throw new IllegalStateException(this + " is already under version control");
            }
        }
    }
}
