/**
 * 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.camel.component.quartz;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.text.ParseException;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.camel.CamelContext;
import org.apache.camel.StartupListener;
import org.apache.camel.impl.UriEndpointComponent;
import org.apache.camel.util.EndpointHelper;
import org.apache.camel.util.IOHelper;
import org.apache.camel.util.IntrospectionSupport;
import org.apache.camel.util.ObjectHelper;
import org.apache.camel.util.ResourceHelper;
import org.quartz.CronTrigger;
import org.quartz.JobDetail;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.SchedulerFactory;
import org.quartz.SimpleTrigger;
import org.quartz.Trigger;
import org.quartz.impl.StdSchedulerFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A <a href="http://camel.apache.org/quartz.html">Quartz Component</a>
 * <p/>
 * For a brief tutorial on setting cron expression see
 * <a href="http://quartz-scheduler.org/documentation/quartz-1.x/tutorials/crontrigger">Quartz cron tutorial</a>.
 *
 * @version
 */
public class QuartzComponent extends UriEndpointComponent implements StartupListener {
    private static final Logger LOG = LoggerFactory.getLogger(QuartzComponent.class);
    private Scheduler scheduler;
    private final transient List<JobToAdd> jobsToAdd = new ArrayList<JobToAdd>();
    private SchedulerFactory factory;
    private Properties properties;
    private String propertiesFile;
    private int startDelayedSeconds;
    private boolean autoStartScheduler = true;
    private boolean enableJmx = true;

    private static final class JobToAdd {
        private final JobDetail job;
        private final Trigger trigger;

        private JobToAdd(JobDetail job, Trigger trigger) {
            this.job = job;
            this.trigger = trigger;
        }

        public JobDetail getJob() {
            return job;
        }

        public Trigger getTrigger() {
            return trigger;
        }
    }

    public QuartzComponent() {
        super(QuartzEndpoint.class);
    }

    public QuartzComponent(final CamelContext context) {
        super(context, QuartzEndpoint.class);
    }

    @Override
    protected QuartzEndpoint createEndpoint(final String uri, final String remaining, final Map<String, Object> parameters) throws Exception {
        // lets split the remaining into a group/name
        URI u = new URI(uri);
        String path = ObjectHelper.after(u.getPath(), "/");
        String host = u.getHost();
        String cron = getAndRemoveParameter(parameters, "cron", String.class);
        Boolean fireNow = getAndRemoveParameter(parameters, "fireNow", Boolean.class, Boolean.FALSE);
        Integer startDelayedSeconds = getAndRemoveParameter(parameters, "startDelayedSeconds", Integer.class);
        if (startDelayedSeconds != null) {
            if (scheduler.isStarted()) {
                LOG.warn("A Quartz job is already started. Cannot apply the 'startDelayedSeconds' configuration!");
            } else if (this.startDelayedSeconds != 0 && !(this.startDelayedSeconds == startDelayedSeconds)) {
                LOG.warn("A Quartz job is already configured with a different 'startDelayedSeconds' configuration! "
                    + "All Quartz jobs must share the same 'startDelayedSeconds' configuration! Cannot apply the 'startDelayedSeconds' configuration!");
            } else {
                this.startDelayedSeconds = startDelayedSeconds;
            }
        }

        // host can be null if the uri did contain invalid host characters such as an underscore
        if (host == null) {
            host = ObjectHelper.before(remaining, "/");
            if (host == null) {
                host = remaining;
            }
        }

        // group can be optional, if so set it to Camel
        String name;
        String group;
        if (ObjectHelper.isNotEmpty(path) && ObjectHelper.isNotEmpty(host)) {
            group = host;
            name = path;
        } else {
            group = "Camel";
            name = host;
        }

        Map<String, Object> triggerParameters = IntrospectionSupport.extractProperties(parameters, "trigger.");
        Map<String, Object> jobParameters = IntrospectionSupport.extractProperties(parameters, "job.");

        Trigger trigger;
        boolean stateful = "true".equals(parameters.get("stateful"));

        // if we're starting up and not running in Quartz clustered mode or not stateful then check for a name conflict.
        if (!isClustered() && !stateful) {
            // check to see if this trigger already exists
            trigger = getScheduler().getTrigger(name, group);
            if (trigger != null) {
                String msg = "A Quartz job already exists with the name/group: " + name + "/" + group;
                throw new IllegalArgumentException(msg);
            }
        }

        // create the trigger either cron or simple
        if (ObjectHelper.isNotEmpty(cron)) {
            cron = encodeCronExpression(cron);
            trigger = createCronTrigger(cron);
        } else {
            trigger = new SimpleTrigger();
            if (fireNow) {
                String intervalString = (String) triggerParameters.get("repeatInterval");
                if (intervalString != null) {
                    long interval = EndpointHelper.resolveParameter(getCamelContext(), intervalString, Long.class);
                    
                    trigger.setStartTime(new Date(System.currentTimeMillis() - interval));
                }
            }
        }

        QuartzEndpoint answer = new QuartzEndpoint(uri, this);
        answer.setGroupName(group);
        answer.setTimerName(name);
        answer.setCron(cron);

        setProperties(answer.getJobDetail(), jobParameters);

        // enrich job data map with trigger information
        if (cron != null) {
            answer.getJobDetail().getJobDataMap().put(QuartzConstants.QUARTZ_TRIGGER_TYPE, "cron");
            answer.getJobDetail().getJobDataMap().put(QuartzConstants.QUARTZ_TRIGGER_CRON_EXPRESSION, cron);
            String timeZone = EndpointHelper.resolveParameter(getCamelContext(), (String)triggerParameters.get("timeZone"), String.class);
            if (timeZone != null) {
                answer.getJobDetail().getJobDataMap().put(QuartzConstants.QUARTZ_TRIGGER_CRON_TIMEZONE, timeZone);
            }
        } else {
            answer.getJobDetail().getJobDataMap().put(QuartzConstants.QUARTZ_TRIGGER_TYPE, "simple");
            Long interval = EndpointHelper.resolveParameter(getCamelContext(), (String)triggerParameters.get("repeatInterval"), Long.class);
            if (interval != null) {
                triggerParameters.put("repeatInterval", interval);
                answer.getJobDetail().getJobDataMap().put(QuartzConstants.QUARTZ_TRIGGER_SIMPLE_REPEAT_INTERVAL, interval);
            }
            Integer counter = EndpointHelper.resolveParameter(getCamelContext(), (String)triggerParameters.get("repeatCount"), Integer.class);
            if (counter != null) {
                triggerParameters.put("repeatCount", counter);
                answer.getJobDetail().getJobDataMap().put(QuartzConstants.QUARTZ_TRIGGER_SIMPLE_REPEAT_COUNTER, counter);
            }
        }

        setProperties(trigger, triggerParameters);
        trigger.setName(name);
        trigger.setGroup(group);
        answer.setTrigger(trigger);

        return answer;
    }

    protected CronTrigger createCronTrigger(String path) throws ParseException {
        CronTrigger cron = new CronTrigger();
        cron.setCronExpression(path);
        return cron;
    }

    private static String encodeCronExpression(String path) {
        // replace + back to space so it's a cron expression
        return path.replaceAll("\\+", " ");
    }

    public void onCamelContextStarted(CamelContext camelContext, boolean alreadyStarted) throws Exception {
        if (scheduler != null) {
            String uid = QuartzHelper.getQuartzContextName(camelContext);
            scheduler.getContext().put(QuartzConstants.QUARTZ_CAMEL_CONTEXT + "-" + uid, camelContext);
        }

        // if not configure to auto start then don't start it
        if (!isAutoStartScheduler()) {
            LOG.info("QuartzComponent configured to not auto start Quartz scheduler.");
            return;
        }

        // only start scheduler when CamelContext has finished starting
        startScheduler();
    }

    @Override
    protected void doStart() throws Exception {
        super.doStart();
        if (scheduler == null) {
            scheduler = getScheduler();
        }
    }

    @Override
    protected void doStop() throws Exception {
        super.doStop();

        if (scheduler != null) {
            AtomicInteger number = (AtomicInteger) scheduler.getContext().get("CamelJobs");
            if (number != null && number.get() > 0) {
                LOG.info("Cannot shutdown Quartz scheduler: " + scheduler.getSchedulerName() + " as there are still " + number.get() + " jobs registered.");
            } else {
                // no more jobs then shutdown the scheduler
                LOG.info("There are no more jobs registered, so shutting down Quartz scheduler: " + scheduler.getSchedulerName());
                scheduler.shutdown();
                scheduler = null;
            }
        }
    }

    public void addJob(JobDetail job, Trigger trigger) throws SchedulerException {
        if (scheduler == null) {
            // add job to internal list because we will defer adding to the scheduler when camel context has been fully started
            jobsToAdd.add(new JobToAdd(job, trigger));
        } else {
            // add job directly to scheduler
            doAddJob(job, trigger);
        }
    }

    private void doAddJob(JobDetail job, Trigger trigger) throws SchedulerException {
        Trigger existingTrigger = getScheduler().getTrigger(trigger.getName(), trigger.getGroup());
        if (existingTrigger == null) {
            LOG.debug("Adding job using trigger: {}/{}", trigger.getGroup(), trigger.getName());
            getScheduler().scheduleJob(job, trigger);
        } else if (hasTriggerChanged(existingTrigger, trigger)) {
            LOG.debug("Trigger: {}/{} already exists and will be updated by Quartz.", trigger.getGroup(), trigger.getName());
            // fast forward start time to now, as we do not want any misfire to kick in
            trigger.setStartTime(new Date());

            // To ensure trigger uses the same job (the job name might change!) we will remove old trigger then re-add.
            scheduler.unscheduleJob(trigger.getName(), trigger.getGroup());
            scheduler.addJob(job, true);
            trigger.setJobName(job.getName());
            trigger.setJobGroup(job.getGroup());
            scheduler.scheduleJob(trigger);
        } else {
            if (!isClustered()) {
                LOG.debug("Trigger: {}/{} already exists and will be resumed by Quartz.", trigger.getGroup(), trigger.getName());
                // fast forward start time to now, as we do not want any misfire to kick in
                trigger.setStartTime(new Date());

                // To ensure trigger uses the same job (the job name might change!) we will remove old trigger then re-add.
                scheduler.unscheduleJob(trigger.getName(), trigger.getGroup());
                scheduler.addJob(job, true);
                trigger.setJobName(job.getName());
                trigger.setJobGroup(job.getGroup());
                scheduler.scheduleJob(trigger);
            } else {
                LOG.debug("Trigger: {}/{} already exists and is already scheduled by clustered JobStore.", trigger.getGroup(), trigger.getName());
            }
        }

        // only increment job counter if we are successful
        incrementJobCounter(getScheduler());
    }

    private static boolean hasTriggerChanged(Trigger oldTrigger, Trigger newTrigger) {
        if (newTrigger instanceof CronTrigger && oldTrigger instanceof CronTrigger) {
            CronTrigger newCron = (CronTrigger) newTrigger;
            CronTrigger oldCron = (CronTrigger) oldTrigger;
            return !newCron.getCronExpression().equals(oldCron.getCronExpression());
        } else if (newTrigger instanceof SimpleTrigger && oldTrigger instanceof SimpleTrigger) {
            SimpleTrigger newSimple = (SimpleTrigger) newTrigger;
            SimpleTrigger oldSimple = (SimpleTrigger) oldTrigger;
            return newSimple.getRepeatInterval() != oldSimple.getRepeatInterval()
                    || newSimple.getRepeatCount() != oldSimple.getRepeatCount();
        } else {
            return !newTrigger.getClass().equals(oldTrigger.getClass()) || !newTrigger.equals(oldTrigger);
        }
    }

    public void pauseJob(Trigger trigger) throws SchedulerException {
        if (isClustered()) {
            // do not pause jobs which are clustered, as we want the jobs to continue running on the other nodes
            LOG.debug("Cannot pause job using trigger: {}/{} as the JobStore is clustered.", trigger.getGroup(), trigger.getName());
        } else {
            LOG.debug("Pausing job using trigger: {}/{}", trigger.getGroup(), trigger.getName());
            getScheduler().pauseTrigger(trigger.getName(), trigger.getGroup());
            getScheduler().pauseJob(trigger.getName(), trigger.getGroup());
        }

        // only decrement job counter if we are successful
        decrementJobCounter(getScheduler());
    }

    public void deleteJob(String name, String group) throws SchedulerException {
        if (isClustered()) {
            // do not pause jobs which are clustered, as we want the jobs to continue running on the other nodes
            LOG.debug("Cannot delete job using trigger: {}/{} as the JobStore is clustered.", group, name);
        } else {
            Trigger trigger = getScheduler().getTrigger(name, group);
            if (trigger != null) {
                LOG.debug("Deleting job using trigger: {}/{}", group, name);
                getScheduler().unscheduleJob(name, group);
            }
        }
    }

    /**
     * To force shutdown the quartz scheduler
     *
     * @throws SchedulerException can be thrown if error shutting down
     */
    public void shutdownScheduler() throws SchedulerException {
        if (scheduler != null) {
            LOG.info("Forcing shutdown of Quartz scheduler: " + scheduler.getSchedulerName());
            scheduler.shutdown();
            scheduler = null;
        }
    }

    /**
     * Is the quartz scheduler clustered?
     */
    public boolean isClustered() throws SchedulerException {
        try {
            return getScheduler().getMetaData().isJobStoreClustered();
        } catch (NoSuchMethodError e) {
            LOG.debug("Job clustering is only supported since Quartz 1.7, isClustered returning false");
            return false;
        }
    }

    /**
     * To force starting the quartz scheduler
     *
     * @throws SchedulerException can be thrown if error starting
     */
    public void startScheduler() throws SchedulerException {
        for (JobToAdd add : jobsToAdd) {
            doAddJob(add.getJob(), add.getTrigger());
        }
        jobsToAdd.clear();

        if (!getScheduler().isStarted()) {
            if (getStartDelayedSeconds() > 0) {
                LOG.info("Starting Quartz scheduler: " + getScheduler().getSchedulerName() + " delayed: " + getStartDelayedSeconds() + " seconds.");
                try {
                    getScheduler().startDelayed(getStartDelayedSeconds());
                } catch (NoSuchMethodError e) {
                    LOG.warn("Your version of Quartz is too old to support delayed startup! "
                        + "Starting Quartz scheduler immediately : " + getScheduler().getSchedulerName());
                    getScheduler().start();
                }
            } else {
                LOG.info("Starting Quartz scheduler: " + getScheduler().getSchedulerName());
                getScheduler().start();
            }
        }
    }

    // Properties
    // -------------------------------------------------------------------------

    public SchedulerFactory getFactory() throws SchedulerException {
        if (factory == null) {
            factory = createSchedulerFactory();
        }
        return factory;
    }

    public void setFactory(SchedulerFactory factory) {
        this.factory = factory;
    }

    public synchronized Scheduler getScheduler() throws SchedulerException {
        if (scheduler == null) {
            scheduler = createScheduler();
        }
        return scheduler;
    }

    /**
     * To use the custom configured Quartz scheduler, instead of creating a new Scheduler.
     */
    public void setScheduler(final Scheduler scheduler) {
        this.scheduler = scheduler;
    }

    public Properties getProperties() {
        return properties;
    }

    /**
     * Properties to configure the Quartz scheduler.
     */
    public void setProperties(Properties properties) {
        this.properties = properties;
    }

    public String getPropertiesFile() {
        return propertiesFile;
    }

    /**
     * File name of the properties to load from the classpath
     */
    public void setPropertiesFile(String propertiesFile) {
        this.propertiesFile = propertiesFile;
    }

    public int getStartDelayedSeconds() {
        return startDelayedSeconds;
    }

    /**
     * Seconds to wait before starting the quartz scheduler.
     */
    public void setStartDelayedSeconds(int startDelayedSeconds) {
        this.startDelayedSeconds = startDelayedSeconds;
    }

    public boolean isAutoStartScheduler() {
        return autoStartScheduler;
    }

    /**
     * Whether or not the scheduler should be auto started.
     * <p/>
     * This options is default true
     */
    public void setAutoStartScheduler(boolean autoStartScheduler) {
        this.autoStartScheduler = autoStartScheduler;
    }

    public boolean isEnableJmx() {
        return enableJmx;
    }

    /**
     * Whether to enable Quartz JMX which allows to manage the Quartz scheduler from JMX.
     * <p/>
     * This options is default true
     */
    public void setEnableJmx(boolean enableJmx) {
        this.enableJmx = enableJmx;
    }

    // Implementation methods
    // -------------------------------------------------------------------------

    protected Properties loadProperties() throws SchedulerException {
        Properties answer = getProperties();
        if (answer == null && getPropertiesFile() != null) {
            LOG.info("Loading Quartz properties file from: {}", getPropertiesFile());
            InputStream is = null;
            try {
                is = ResourceHelper.resolveMandatoryResourceAsInputStream(getCamelContext().getClassResolver(), getPropertiesFile());
                answer = new Properties();
                answer.load(is);
            } catch (IOException e) {
                throw new SchedulerException("Error loading Quartz properties file: " + getPropertiesFile(), e);
            } finally {
                IOHelper.close(is);
            }
        }
        return answer;
    }

    protected SchedulerFactory createSchedulerFactory() throws SchedulerException {
        SchedulerFactory answer;

        Properties prop = loadProperties();
        if (prop != null) {

            // force disabling update checker (will do online check over the internet)
            prop.put("org.quartz.scheduler.skipUpdateCheck", "true");

            // camel context name will be a suffix to use one scheduler per context
            String instName = createInstanceName(prop);
            prop.setProperty(StdSchedulerFactory.PROP_SCHED_INSTANCE_NAME, instName);

            // enable jmx unless configured to not do so
            if (enableJmx && !prop.containsKey("org.quartz.scheduler.jmx.export")) {
                LOG.info("Setting org.quartz.scheduler.jmx.export=true to ensure QuartzScheduler(s) will be enlisted in JMX.");
                prop.put("org.quartz.scheduler.jmx.export", "true");
            }

            answer = new StdSchedulerFactory(prop);
        } else {
            // read default props to be able to use a single scheduler per camel context
            // if we need more than one scheduler per context use setScheduler(Scheduler) 
            // or setFactory(SchedulerFactory) methods

            // must use classloader from StdSchedulerFactory to work even in OSGi
            InputStream is = StdSchedulerFactory.class.getClassLoader().getResourceAsStream("org/quartz/quartz.properties");
            if (is == null) {
                throw new SchedulerException("Quartz properties file not found in classpath: org/quartz/quartz.properties");
            }
            prop = new Properties();
            try {
                prop.load(is);
            } catch (IOException e) {
                throw new SchedulerException("Error loading Quartz properties file from classpath: org/quartz/quartz.properties", e);
            } finally {
                IOHelper.close(is);
            }

            // camel context name will be a suffix to use one scheduler per context
            String instName = createInstanceName(prop);
            prop.setProperty(StdSchedulerFactory.PROP_SCHED_INSTANCE_NAME, instName);

            // force disabling update checker (will do online check over the internet)
            prop.put("org.quartz.scheduler.skipUpdateCheck", "true");

            // enable jmx unless configured to not do so
            if (enableJmx && !prop.containsKey("org.quartz.scheduler.jmx.export")) {
                prop.put("org.quartz.scheduler.jmx.export", "true");
                LOG.info("Setting org.quartz.scheduler.jmx.export=true to ensure QuartzScheduler(s) will be enlisted in JMX.");
            }

            answer = new StdSchedulerFactory(prop);
        }

        if (LOG.isDebugEnabled()) {
            String name = prop.getProperty(StdSchedulerFactory.PROP_SCHED_INSTANCE_NAME);
            LOG.debug("Creating SchedulerFactory: {} with properties: {}", name, prop);
        }
        return answer;
    }

    protected String createInstanceName(Properties prop) {
        String instName = prop.getProperty(StdSchedulerFactory.PROP_SCHED_INSTANCE_NAME);

        // camel context name will be a suffix to use one scheduler per context
        String identity = QuartzHelper.getQuartzContextName(getCamelContext());
        if (identity != null) {
            if (instName == null) {
                instName = "scheduler-" + identity;
            } else {
                instName = instName + "-" + identity;
            }
        }
        return instName;
    }

    protected Scheduler createScheduler() throws SchedulerException {
        Scheduler scheduler = getFactory().getScheduler();

        if (LOG.isDebugEnabled()) {
            LOG.debug("Using SchedulerFactory {} to get/create Scheduler {}({})",
                    new Object[]{getFactory(), scheduler, ObjectHelper.getIdentityHashCode(scheduler)});
        }

        // register current camel context to scheduler so we can look it up when jobs is being triggered
        // must use management name as it should be unique in the same JVM
        String uid = QuartzHelper.getQuartzContextName(getCamelContext());
        scheduler.getContext().put(QuartzConstants.QUARTZ_CAMEL_CONTEXT + "-" + uid, getCamelContext());

        // store Camel job counter
        AtomicInteger number = (AtomicInteger) scheduler.getContext().get("CamelJobs");
        if (number == null) {
            number = new AtomicInteger(0);
            scheduler.getContext().put("CamelJobs", number);
        }

        return scheduler;
    }

    private static void decrementJobCounter(Scheduler scheduler) throws SchedulerException {
        AtomicInteger number = (AtomicInteger) scheduler.getContext().get("CamelJobs");
        if (number != null) {
            number.decrementAndGet();
        }
    }

    private static void incrementJobCounter(Scheduler scheduler) throws SchedulerException {
        AtomicInteger number = (AtomicInteger) scheduler.getContext().get("CamelJobs");
        if (number != null) {
            number.incrementAndGet();
        }
    }

}
