/**
 * 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.activemq.broker.scheduler;

import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;

import jakarta.jms.MessageFormatException;

import org.apache.activemq.ScheduledMessage;
import org.apache.activemq.advisory.AdvisorySupport;
import org.apache.activemq.broker.Broker;
import org.apache.activemq.broker.BrokerFilter;
import org.apache.activemq.broker.BrokerService;
import org.apache.activemq.broker.Connection;
import org.apache.activemq.broker.ConnectionContext;
import org.apache.activemq.broker.Connector;
import org.apache.activemq.broker.ProducerBrokerExchange;
import org.apache.activemq.broker.region.ConnectionStatistics;
import org.apache.activemq.command.ActiveMQDestination;
import org.apache.activemq.command.Command;
import org.apache.activemq.command.ConnectionControl;
import org.apache.activemq.command.ExceptionResponse;
import org.apache.activemq.command.Message;
import org.apache.activemq.command.MessageId;
import org.apache.activemq.command.ProducerId;
import org.apache.activemq.command.ProducerInfo;
import org.apache.activemq.command.Response;
import org.apache.activemq.openwire.OpenWireFormat;
import org.apache.activemq.security.SecurityContext;
import org.apache.activemq.state.ProducerState;
import org.apache.activemq.transaction.Synchronization;
import org.apache.activemq.usage.JobSchedulerUsage;
import org.apache.activemq.usage.SystemUsage;
import org.apache.activemq.util.ByteSequence;
import org.apache.activemq.util.IdGenerator;
import org.apache.activemq.util.LongSequenceGenerator;
import org.apache.activemq.util.TypeConversionSupport;
import org.apache.activemq.wireformat.WireFormat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SchedulerBroker extends BrokerFilter implements JobListener {
    private static final Logger LOG = LoggerFactory.getLogger(SchedulerBroker.class);
    private static final IdGenerator ID_GENERATOR = new IdGenerator();
    private static final LongSequenceGenerator longGenerator = new LongSequenceGenerator();
    /**
     * The max repeat value allowed to prevent clients from causing DoS issues with huge repeat counts
     */
    private static final int MAX_REPEAT_ALLOWED = 1000;
    private final LongSequenceGenerator messageIdGenerator = new LongSequenceGenerator();
    private final AtomicBoolean started = new AtomicBoolean();
    private final WireFormat wireFormat = new OpenWireFormat();
    private final ConnectionContext context = new ConnectionContext();
    private final ProducerId producerId = new ProducerId();
    private final SystemUsage systemUsage;

    private final JobSchedulerStore store;
    private JobScheduler scheduler;
    private int maxRepeatAllowed = MAX_REPEAT_ALLOWED;

    public SchedulerBroker(BrokerService brokerService, Broker next, JobSchedulerStore store) throws Exception {
        super(next);

        this.store = store;
        this.producerId.setConnectionId(ID_GENERATOR.generateId());
        this.context.setSecurityContext(SecurityContext.BROKER_SECURITY_CONTEXT);
        // we only get response on unexpected error
        this.context.setConnection(new Connection() {

            private final long connectedTimestamp = System.currentTimeMillis();

            @Override
            public Connector getConnector() {
                return null;
            }

            @Override
            public void dispatchSync(Command message) {
                if (message instanceof ExceptionResponse) {
                    LOG.warn("Unexpected response: {}", message);
                }
            }

            @Override
            public void dispatchAsync(Command command) {
                if (command instanceof ExceptionResponse) {
                    LOG.warn("Unexpected response: {}", command);
                }
            }

            @Override
            public Response service(Command command) {
                return null;
            }

            @Override
            public void serviceException(Throwable error) {
                LOG.warn("Unexpected exception", error);
            }

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

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

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

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

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

            @Override
            public ConnectionStatistics getStatistics() {
                return null;
            }

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

            @Override
            public String getRemoteAddress() {
                return null;
            }

            @Override
            public void serviceExceptionAsync(IOException e) {
                LOG.warn("Unexpected async ioexception", e);
            }

            @Override
            public String getConnectionId() {
                return null;
            }

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

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

            @Override
            public void updateClient(ConnectionControl control) {}

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

            @Override
            public Long getOldestActiveTransactionDuration() {
                return null;
            }

            
            @Override
            public Long getConnectedTimestamp() {
                return connectedTimestamp;
            }

            @Override
            public void start() throws Exception {}

            @Override
            public void stop() throws Exception {}
        });
        this.context.setBroker(next);
        this.systemUsage = brokerService.getSystemUsage();

        wireFormat.setVersion(brokerService.getStoreOpenWireVersion());
    }

    public synchronized JobScheduler getJobScheduler() throws Exception {
        return new JobSchedulerFacade(this);
    }

    @Override
    public void start() throws Exception {
        this.started.set(true);
        getInternalScheduler();
        super.start();
    }

    @Override
    public void stop() throws Exception {
        if (this.started.compareAndSet(true, false)) {

            if (this.store != null) {
                this.store.stop();
            }
            if (this.scheduler != null) {
                this.scheduler.removeListener(this);
                this.scheduler = null;
            }
        }
        super.stop();
    }

    @Override
    public void send(ProducerBrokerExchange producerExchange, final Message messageSend) throws Exception {
        ConnectionContext context = producerExchange.getConnectionContext();

        final String jobId = (String) messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULED_ID);
        final Object cronValue = messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULED_CRON);
        final Object periodValue = messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULED_PERIOD);
        final Object delayValue = messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULED_DELAY);

        String physicalName = messageSend.getDestination().getPhysicalName();
        boolean schedulerManage = physicalName.regionMatches(true, 0, ScheduledMessage.AMQ_SCHEDULER_MANAGEMENT_DESTINATION, 0,
            ScheduledMessage.AMQ_SCHEDULER_MANAGEMENT_DESTINATION.length());

        if (schedulerManage == true) {

            JobScheduler scheduler = getInternalScheduler();
            ActiveMQDestination replyTo = messageSend.getReplyTo();

            String action = (String) messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULER_ACTION);

            if (action != null) {

                Object startTime = messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULER_ACTION_START_TIME);
                Object endTime = messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULER_ACTION_END_TIME);

                if (replyTo != null && action.equals(ScheduledMessage.AMQ_SCHEDULER_ACTION_BROWSE)) {

                    if (startTime != null && endTime != null) {

                        long start = (Long) TypeConversionSupport.convert(startTime, Long.class);
                        long finish = (Long) TypeConversionSupport.convert(endTime, Long.class);

                        for (Job job : scheduler.getAllJobs(start, finish)) {
                            sendScheduledJob(producerExchange.getConnectionContext(), job, replyTo);
                        }
                    } else {
                        for (Job job : scheduler.getAllJobs()) {
                            sendScheduledJob(producerExchange.getConnectionContext(), job, replyTo);
                        }
                    }
                }
                if (jobId != null && action.equals(ScheduledMessage.AMQ_SCHEDULER_ACTION_REMOVE)) {
                    scheduler.remove(jobId);
                } else if (action.equals(ScheduledMessage.AMQ_SCHEDULER_ACTION_REMOVEALL)) {

                    if (startTime != null && endTime != null) {

                        long start = (Long) TypeConversionSupport.convert(startTime, Long.class);
                        long finish = (Long) TypeConversionSupport.convert(endTime, Long.class);

                        scheduler.removeAllJobs(start, finish);
                    } else {
                        scheduler.removeAllJobs();
                    }
                }
            }

        } else if ((cronValue != null || periodValue != null || delayValue != null) && jobId == null) {

            // Check for room in the job scheduler store
            if (systemUsage.getJobSchedulerUsage() != null) {
                JobSchedulerUsage usage = systemUsage.getJobSchedulerUsage();
                if (usage.isFull()) {
                    final String logMessage = "Job Scheduler Store is Full (" +
                        usage.getPercentUsage() + "% of " + usage.getLimit() +
                        "). Stopping producer (" + messageSend.getProducerId() +
                        ") to prevent flooding of the job scheduler store." +
                        " See http://activemq.apache.org/producer-flow-control.html for more info";

                    long start = System.currentTimeMillis();
                    long nextWarn = start;
                    while (!usage.waitForSpace(1000)) {
                        if (context.getStopping().get()) {
                            throw new IOException("Connection closed, send aborted.");
                        }

                        long now = System.currentTimeMillis();
                        if (now >= nextWarn) {
                            LOG.info("{}: {} (blocking for: {}s)", usage, logMessage, (now - start) / 1000);
                            nextWarn = now + 30000l;
                        }
                    }
                }
            }

            if (context.isInTransaction()) {
                context.getTransaction().addSynchronization(new Synchronization() {
                    @Override
                    public void afterCommit() throws Exception {
                        doSchedule(messageSend, cronValue, periodValue, delayValue);
                    }
                });
            } else {
                doSchedule(messageSend, cronValue, periodValue, delayValue);
            }
        } else {
            super.send(producerExchange, messageSend);
        }
    }

    private void doSchedule(Message messageSend, Object cronValue, Object periodValue, Object delayValue) throws Exception {
        long delay = 0;
        long period = 0;
        int repeat = 0;
        String cronEntry = "";

        // clear transaction context
        Message msg = messageSend.copy();
        msg.setTransactionId(null);
        org.apache.activemq.util.ByteSequence packet = wireFormat.marshal(msg);
        if (cronValue != null) {
            cronEntry = cronValue.toString();
        }
        if (periodValue != null) {
            period = (Long) TypeConversionSupport.convert(periodValue, Long.class);
        }
        if (delayValue != null) {
            delay = (Long) TypeConversionSupport.convert(delayValue, Long.class);
        }
        Object repeatValue = msg.getProperty(ScheduledMessage.AMQ_SCHEDULED_REPEAT);
        if (repeatValue != null) {
            repeat = (Integer) TypeConversionSupport.convert(repeatValue, Integer.class);
            if (repeat > maxRepeatAllowed) {
                throw new MessageFormatException("The scheduled repeat value is too large");
            }
        }

        //job id should be unique for every job (Same format as MessageId)
        MessageId jobId = new MessageId(messageSend.getMessageId().getProducerId(), longGenerator.getNextSequenceId());

        getInternalScheduler().schedule(jobId.toString(),
                new ByteSequence(packet.data, packet.offset, packet.length), cronEntry, delay, period, repeat);
    }

    @Override
    public void scheduledJob(String id, ByteSequence job) {
        org.apache.activemq.util.ByteSequence packet = new org.apache.activemq.util.ByteSequence(job.getData(), job.getOffset(), job.getLength());
        try {
            Message messageSend = (Message) wireFormat.unmarshal(packet);
            messageSend.setOriginalTransactionId(null);
            Object repeatValue = messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULED_REPEAT);
            Object cronValue = messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULED_CRON);
            String cronStr = cronValue != null ? cronValue.toString() : null;
            int repeat = 0;
            if (repeatValue != null) {
                repeat = (Integer) TypeConversionSupport.convert(repeatValue, Integer.class);
                if (repeat > maxRepeatAllowed) {
                    throw new MessageFormatException("The scheduled repeat value is too large");
                }
            }

            if (repeat != 0 || cronStr != null && cronStr.length() > 0) {
                // create a unique id - the original message could be sent
                // lots of times
                messageSend.setMessageId(new MessageId(producerId, messageIdGenerator.getNextSequenceId()));
            }

            // Add the jobId as a property
            messageSend.setProperty("scheduledJobId", id);

            // if this goes across a network - we don't want it rescheduled
            messageSend.removeProperty(ScheduledMessage.AMQ_SCHEDULED_PERIOD);
            messageSend.removeProperty(ScheduledMessage.AMQ_SCHEDULED_DELAY);
            messageSend.removeProperty(ScheduledMessage.AMQ_SCHEDULED_REPEAT);
            messageSend.removeProperty(ScheduledMessage.AMQ_SCHEDULED_CRON);

            if (messageSend.getTimestamp() > 0 && messageSend.getExpiration() > 0) {

                long oldExpiration = messageSend.getExpiration();
                long newTimeStamp = System.currentTimeMillis();
                long timeToLive = 0;
                long oldTimestamp = messageSend.getTimestamp();

                if (oldExpiration > 0) {
                    timeToLive = oldExpiration - oldTimestamp;
                }

                long expiration = timeToLive + newTimeStamp;

                if (expiration > oldExpiration) {
                    if (timeToLive > 0 && expiration > 0) {
                        messageSend.setExpiration(expiration);
                    }
                    messageSend.setTimestamp(newTimeStamp);
                    LOG.debug("Set message {} timestamp from {} to {}",
                            messageSend.getMessageId(), oldTimestamp, newTimeStamp);
                }
            }

            // Repackage the message contents prior to send now that all updates are complete.
            messageSend.beforeMarshall(wireFormat);

            final ProducerBrokerExchange producerExchange = new ProducerBrokerExchange();
            producerExchange.setConnectionContext(context);
            producerExchange.setMutable(true);
            producerExchange.setProducerState(new ProducerState(new ProducerInfo()));
            super.send(producerExchange, messageSend);
        } catch (Exception e) {
            LOG.error("Failed to send scheduled message {}", id, e);
        }
    }

    protected synchronized JobScheduler getInternalScheduler() throws Exception {
        if (this.started.get()) {
            if (this.scheduler == null && store != null) {
                this.scheduler = store.getJobScheduler("JMS");
                this.scheduler.addListener(this);
                this.scheduler.startDispatching();
            }
            return this.scheduler;
        }
        return null;
    }

    protected void sendScheduledJob(ConnectionContext context, Job job, ActiveMQDestination replyTo) throws Exception {

        org.apache.activemq.util.ByteSequence packet = new org.apache.activemq.util.ByteSequence(job.getPayload());
        try {
            Message msg = (Message) this.wireFormat.unmarshal(packet);
            msg.setOriginalTransactionId(null);
            msg.setPersistent(false);
            msg.setType(AdvisorySupport.ADIVSORY_MESSAGE_TYPE);
            msg.setMessageId(new MessageId(this.producerId, this.messageIdGenerator.getNextSequenceId()));

            // Preserve original destination
            msg.setOriginalDestination(msg.getDestination());

            msg.setDestination(replyTo);
            msg.setResponseRequired(false);
            msg.setProducerId(this.producerId);

            // Add the jobId as a property
            msg.setProperty("scheduledJobId", job.getJobId());

            final boolean originalFlowControl = context.isProducerFlowControl();
            final ProducerBrokerExchange producerExchange = new ProducerBrokerExchange();
            producerExchange.setConnectionContext(context);
            producerExchange.setMutable(true);
            producerExchange.setProducerState(new ProducerState(new ProducerInfo()));
            try {
                context.setProducerFlowControl(false);
                this.next.send(producerExchange, msg);
            } finally {
                context.setProducerFlowControl(originalFlowControl);
            }
        } catch (Exception e) {
            LOG.error("Failed to send scheduled message {}", job.getJobId(), e);
        }
    }

    public int getMaxRepeatAllowed() {
        return maxRepeatAllowed;
    }

    public void setMaxRepeatAllowed(int maxRepeatAllowed) {
        this.maxRepeatAllowed = maxRepeatAllowed;
    }
}
