/*
 * Copyright 2014, The OpenNMS Group
 * 
 * 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 org.opennms.newts.api.query;


import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

import java.io.Serializable;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;

import org.apache.commons.jexl3.JexlBuilder;
import org.apache.commons.jexl3.JexlContext;
import org.apache.commons.jexl3.JexlEngine;
import org.apache.commons.jexl3.JexlExpression;
import org.apache.commons.jexl3.MapContext;
import org.apache.commons.jexl3.introspection.JexlSandbox;
import org.opennms.newts.api.Duration;

import com.google.common.base.Function;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;


public class ResultDescriptor implements Serializable {
    private static final long serialVersionUID = -6983442401680715547L;

    private final static JexlEngine JEXL_ENGINE;

    static {
        final JexlSandbox jexlSandbox = new JexlSandbox(false);
        jexlSandbox.white(String.class.getName());

        JEXL_ENGINE = new JexlBuilder()
                .sandbox(jexlSandbox)
                .create();
    }

    public static interface UnaryFunction extends Serializable {
        double apply(double a);
    }

    public static interface BinaryFunction extends Serializable {
        double apply(double a, double b);
    }

    /**
     * The default step size in milliseconds.
     */
    public static final int DEFAULT_STEP = 300000;

    /**
     * Multiple of the step size to use as default heartbeat.
     */
    public static final int DEFAULT_HEARTBEAT_MULTIPLIER = 2;

    /**
     * Default X Files Factor (percentage of NaN pdps that are allowed when aggregating)
     */
    public static final double DEFAULT_XFF = 0.5;

    private Duration m_interval;
    private final Map<String, Datasource> m_datasources = Maps.newHashMap();
    // use linkedHashMap so creation order is preserved. calculations can only depend on earlier not later calculations
    private final Map<String, Calculation> m_calculations = Maps.newLinkedHashMap();

    private final Set<String> m_exports = Sets.newHashSet();

    /**
     * Constructs a new {@link ResultDescriptor} with the default step size.
     */
    public ResultDescriptor() {
        this(DEFAULT_STEP);
    }

    /**
     * Constructs a new {@link ResultDescriptor} with the given step size.
     * 
     * @param step
     *            duration in milliseconds
     */
    public ResultDescriptor(long step) {
        this(Duration.millis(step));
    }

    /**
     * Constructs a new {@link ResultDescriptor} with the given step size.
     * 
     * @param step
     *            duration as an instance of {@link Duration}
     */
    public ResultDescriptor(Duration step) {
        m_interval = step;
    }

    public Duration getInterval() {
        return m_interval;
    }

    public Map<String, Datasource> getDatasources() {
        return m_datasources;
    }

    public Map<String, Calculation> getCalculations() {
        return m_calculations;
    }

    /**
     * Returns the set of unique source names; The names of the underlying samples used as the
     * source of aggregations.
     * 
     * @return source names
     */
    public Set<String> getSourceNames() {
        return Sets.newHashSet(Iterables.transform(getDatasources().values(), new Function<Datasource, String>() {

            @Override
            public String apply(Datasource input) {
                return input.getSource();
            }
        }));
    }

    public Set<String> getLabels() {
        return Sets.union(m_datasources.keySet(), m_calculations.keySet());
    }

    public Set<String> getExports() {
        return m_exports;
    }

    /**
     * Set the step duration.
     * 
     * @param step
     *            duration in milliseconds
     * @return
     */
    public ResultDescriptor step(long step) {
        return step(Duration.millis(step));
    }

    public ResultDescriptor step(Duration step) {
        m_interval = step;
        return this;
    }

    public ResultDescriptor datasource(String metricName, AggregationFunction aggregationFunction) {
        return datasource(metricName, metricName, aggregationFunction);
    }

    public ResultDescriptor datasource(String name, String metricName, AggregationFunction aggregationFunction) {
        return datasource(name, metricName, getInterval().times(DEFAULT_HEARTBEAT_MULTIPLIER), aggregationFunction);
    }

    public ResultDescriptor datasource(String name, String metricName, long heartbeat, AggregationFunction aggregationFunction) {
        return datasource(name, metricName, Duration.millis(heartbeat), aggregationFunction);
    }

    public ResultDescriptor datasource(String name, String metricName, Duration heartbeat, AggregationFunction aggregationFunction) {
        return datasource(name, metricName, heartbeat, DEFAULT_XFF, aggregationFunction);
    }

    public ResultDescriptor datasource(String name, String metricName, Duration heartbeat, double xff, AggregationFunction aggregationFunction) {
        return datasource(new Datasource(name, metricName, heartbeat, xff, aggregationFunction));
    }

    ResultDescriptor datasource(Datasource ds) {
        checkNotNull(ds, "data source argument");
        checkArgument(!getLabels().contains(ds.getLabel()), "label \"%s\" already in use", ds.getLabel());
        checkArgument(ds.getHeartbeat().gte(getInterval()), "heartbeat cannot be smaller than sample interval");

        getDatasources().put(ds.getLabel(), ds);

        return this;
    }

    public ResultDescriptor export(String... names) {
        checkLabels(names);
        getExports().addAll(Arrays.asList(names));
        return this;
    }

    private void checkLabels(String... names) {
        Set<String> missing = Sets.newHashSet(names);
        missing.removeAll(getLabels());

        if (!missing.isEmpty()) {
            throw new IllegalArgumentException(String.format("No such labels(s): %s", missing));
        }
    }
    
    private boolean isNumber(String number) {
        try {
            Double.parseDouble(number);
            return true;
        } catch(NumberFormatException e) {
            return false;
        }
    }
     
    private void checkValues(String... values) {
        for(String value : values) {
            if (!isNumber(value)) {
                checkLabels(value);
            }
        }
    }

    public ResultDescriptor calculate(Calculation calculation) {
        checkValues(calculation.getArgs());
        checkArgument(!getLabels().contains(calculation.getLabel()), "label \"%s\" already in use", calculation.getLabel());
        m_calculations.put(calculation.getLabel(), calculation);
        return this;
    }

    public ResultDescriptor calculate(String label, CalculationFunction calculationFunction, String... args) {
        return calculate(new Calculation(label, calculationFunction, args));
    }

    public ResultDescriptor calculate(String label, final BinaryFunction binaryFunction, String arg1, String arg2) {
        CalculationFunction calculationFunction = new CalculationFunction() {
            private static final long serialVersionUID = -4149546450724920349L;

            @Override
            public double apply(double... ds) {
                checkArgument(ds.length == 2, "binaryFunctions expect to take exactly two arguments but we've been passed "
                        + ds.length);
                return binaryFunction.apply(ds[0], ds[1]);
            }
        };
        return calculate(label, calculationFunction, arg1, arg2);

    }

    public ResultDescriptor calculate(String label, final UnaryFunction unaryFunction, String arg) {
        CalculationFunction calculationFunction = new CalculationFunction() {
            private static final long serialVersionUID = 2353112913166459161L;

            @Override
            public double apply(double... ds) {
                checkArgument(ds.length == 1, "unaryFunctions expect to take exactly one argument but we've been passed "
                        + ds.length);
                return unaryFunction.apply(ds[0]);
            }
        };
        return calculate(label, calculationFunction, arg);

    }
    
    public ResultDescriptor expression(String label, String expression) {
        final JexlExpression expr = JEXL_ENGINE.createExpression(expression);
        final String[] labels = getLabels().toArray(new String[0]);
        CalculationFunction evaluate = new CalculationFunction() {
            private static final long serialVersionUID = -3328049421398096252L;

            @Override
            public double apply(double... ds) {
                JexlContext jc = new MapContext();
                for(int i = 0; i < labels.length; i++) {
                    jc.set(labels[i], ds[i]);
                }
                return ((Number)expr.evaluate(jc)).doubleValue();
                
            }
        };
        return calculate(label, evaluate, labels);
    }

    @Override
    public String toString() {
        return String.format(
                "%s[interval=%s, datasources=%s, calculations=%s, exports=%s]",
                getClass().getSimpleName(),
                getInterval(),
                getDatasources().values(),
                getCalculations().values(),
                getExports());
    }

}
