/*
 * RHQ Management Platform
 * Copyright (C) 2005-2008 Red Hat, Inc.
 * All rights reserved.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation version 2 of the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 */
package org.rhq.plugins.script;

import java.io.File;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.rhq.core.domain.configuration.Configuration;
import org.rhq.core.domain.configuration.Property;
import org.rhq.core.domain.configuration.PropertyList;
import org.rhq.core.domain.configuration.PropertyMap;
import org.rhq.core.domain.configuration.PropertySimple;
import org.rhq.core.domain.measurement.AvailabilityType;
import org.rhq.core.domain.measurement.DataType;
import org.rhq.core.domain.measurement.MeasurementDataNumeric;
import org.rhq.core.domain.measurement.MeasurementDataTrait;
import org.rhq.core.domain.measurement.MeasurementReport;
import org.rhq.core.domain.measurement.MeasurementScheduleRequest;
import org.rhq.core.pluginapi.inventory.InvalidPluginConfigurationException;
import org.rhq.core.pluginapi.inventory.ResourceComponent;
import org.rhq.core.pluginapi.inventory.ResourceContext;
import org.rhq.core.pluginapi.measurement.MeasurementFacet;
import org.rhq.core.pluginapi.operation.OperationFacet;
import org.rhq.core.pluginapi.operation.OperationResult;
import org.rhq.core.system.ProcessExecution;
import org.rhq.core.system.ProcessExecutionResults;
import org.rhq.core.system.SystemInfo;
import org.rhq.core.util.exception.ThrowableUtil;

/**
 * Represents a managed resource whose management interface is a command line executable or script,
 * sometimes referred to as the "CLI" (command line interface).
 *
 * @author John Mazzitelli
 */
public class ScriptServerComponent implements ResourceComponent, MeasurementFacet, OperationFacet {

    private final Log log = LogFactory.getLog(ScriptServerComponent.class);

    private static final long MAX_WAIT_TIME = 3600000L;

    protected static final String PLUGINCONFIG_EXECUTABLE = "executable";
    protected static final String PLUGINCONFIG_WORKINGDIR = "workingDirectory";
    protected static final String PLUGINCONFIG_ENVVARS = "environmentVariables";
    protected static final String PLUGINCONFIG_ENVVAR_NAME = "name";
    protected static final String PLUGINCONFIG_ENVVAR_VALUE = "value";
    protected static final String PLUGINCONFIG_AVAIL_EXECUTE_CHECK = "availabilityExecuteCheck";
    protected static final String PLUGINCONFIG_AVAIL_EXITCODE_REGEX = "availabilityExitCodeRegex";
    protected static final String PLUGINCONFIG_AVAIL_OUTPUT_REGEX = "availabilityOutputRegex";
    protected static final String PLUGINCONFIG_AVAIL_ARGS = "availabilityArguments";
    protected static final String PLUGINCONFIG_VERSION_ARGS = "versionArguments";
    protected static final String PLUGINCONFIG_VERSION_REGEX = "versionRegex";
    protected static final String PLUGINCONFIG_FIXED_VERSION = "fixedVersion";
    protected static final String PLUGINCONFIG_DESC_ARGS = "descriptionArguments";
    protected static final String PLUGINCONFIG_DESC_REGEX = "descriptionRegex";
    protected static final String PLUGINCONFIG_FIXED_DESC = "fixedDescription";

    protected static final String OPERATION_PARAM_ARGUMENTS = "arguments";
    protected static final String OPERATION_RESULT_EXITCODE = "exitCode";
    protected static final String OPERATION_RESULT_OUTPUT = "output";

    protected static final String METRIC_PROPERTY_ARGUMENTS = "arguments";
    protected static final String METRIC_PROPERTY_REGEX = "regex";
    protected static final String METRIC_PROPERTY_EXITCODE = "exitcode";

    private Configuration resourceConfiguration;
    private ResourceContext resourceContext;

    public void start(ResourceContext context) {
        if (log.isDebugEnabled()) {
            log.debug("Script Server started: " + context.getPluginConfiguration());
        }

        this.resourceContext = context;
    }

    public void stop() {
        if (log.isDebugEnabled()) {
            log.debug("Script Server stopped: " + this.resourceContext.getPluginConfiguration());
        }
    }

    public AvailabilityType getAvailability() {
        boolean result = checkAvailability();
        return result ? AvailabilityType.UP : AvailabilityType.DOWN;
    }

    /**
     * Executes the CLI and based on the measurement property, collects the appropriate measurement value.
     * This supports measurement data and traits.
     * A metric property can be a string that is assumed the arguments to pass to the CLI. However, if
     * the property is in the form "{arguments}|regex", the arguments are passed to the CLI and the metric
     * value is taken from the regex match. Examples of metric properties:
     *  
     * <ul>
     * <li>"-a --arg2=123" - passes that string as the arguments list, the metric value is the output of the CLI</li> 
     * <li>"{}|exitcode" - no arguments are passed to the CLI and the metric value is the exit code of the CLI</li>
     * <li>"{-a}|[lL]abel(\p{Digit}+)[\r\n]*" - "-a" is the argument passed to the CLI and the metric value is the digits following the label of the output</li>
     * <li>"{}|[ABC]+" - no arguments are passed and the value is the output of the CLI assuming it matches the regex</li>
     * <li>"{--foobar}|foobar (.*) blah" - passes "--foobar" as the argument and the metric value is the string that matches the regex group</li>
     * </ul>
     * 
     * @see MeasurementFacet#getValues(MeasurementReport, Set)
     */
    public void getValues(MeasurementReport report, Set<MeasurementScheduleRequest> requests) {
        Map<String, ProcessExecutionResults> exeResultsCache = new HashMap<String, ProcessExecutionResults>();

        for (MeasurementScheduleRequest request : requests) {
            String metricPropertyName = request.getName();
            boolean dataMustBeNumeric;

            if (request.getDataType() == DataType.MEASUREMENT) {
                dataMustBeNumeric = true;
            } else if (request.getDataType() == DataType.TRAIT) {
                dataMustBeNumeric = false;
            } else {
                log.error("Plugin does not support metric [" + metricPropertyName + "] of type ["
                    + request.getDataType() + "]");
                continue;
            }

            try {

                // determine how to execute the CLI for this metric
                Map<String, String> argsRegex = parseMetricProperty(metricPropertyName);
                Object dataValue;

                if (argsRegex == null) {
                    continue; // this metric's property was invalid, skip this one and move on to the next
                }

                String arguments = argsRegex.get(METRIC_PROPERTY_ARGUMENTS);
                String regex = argsRegex.get(METRIC_PROPERTY_REGEX);
                boolean valueIsExitCode = METRIC_PROPERTY_EXITCODE.equals(regex);

                // if we already executed it with the same arguments, don't bother doing it again
                ProcessExecutionResults exeResults = exeResultsCache.get((arguments == null) ? "" : arguments);
                if (exeResults == null) {
                    boolean captureOutput = !valueIsExitCode; // don't need output if we need to just check exit code
                    exeResults = executeExecutable(arguments, MAX_WAIT_TIME, captureOutput);
                    exeResultsCache.put((arguments == null) ? "" : arguments, exeResults);
                }

                // don't report a metric value if the CLI failed to execute
                if (exeResults.getError() != null) {
                    log.error("Cannot collect CLI metric [" + metricPropertyName + "]. Cause: "
                        + ThrowableUtil.getAllMessages(exeResults.getError()));
                    continue;
                }

                // set dataValue to the appropriate value based on how the metric property defined it
                if (valueIsExitCode) {
                    dataValue = exeResults.getExitCode();
                    if (dataValue == null) {
                        log.error("Could not determine exit code for metric property [" + metricPropertyName
                            + "] - metric will not be collected");
                        continue;
                    }
                } else if (regex != null) {
                    final String output = exeResults.getCapturedOutput();
                    if (output == null) {
                        log.error("Could not get output for metric property [" + metricPropertyName
                            + "] -- metric will not be collected");
                        continue;
                    }

                    Pattern pattern = Pattern.compile(regex);
                    Matcher match = pattern.matcher(output);
                    if (match.find()) {
                        if (match.groupCount() > 0) {
                            dataValue = match.group(1);
                        } else {
                            dataValue = output;
                        }
                    } else {
                        log.error("Output did not match metric property [" + metricPropertyName
                            + "] - metric will not be collected: " + truncateString(output));
                        continue;
                    }
                } else {
                    dataValue = exeResults.getCapturedOutput();
                    if (dataValue == null) {
                        log.error("Could not get output for metric property [" + metricPropertyName
                            + "] - metric will not be collected");
                        continue;
                    }
                }

                // add the metric value to the measurement report
                if (dataMustBeNumeric) {
                    Double numeric = Double.parseDouble(dataValue.toString().trim());
                    report.addData(new MeasurementDataNumeric(request, numeric.doubleValue()));
                } else {
                    report.addData(new MeasurementDataTrait(request, dataValue.toString().trim()));
                }
            } catch (Exception e) {
                log.error("Failed to obtain measurement [" + metricPropertyName + "]. Cause: " + e);
            }
        }

        exeResultsCache.clear(); // help out the garbage collector, since this could be alot of data
        exeResultsCache = null;

        return;
    }

    /**
     * Given a metric property name, this parses it into its different pieces and returns a
     * map of the different tokens. The format of the metric property one of the following:
     * <pre>
     * arguments
     * {arguments}|regex
     * {arguments}|exitcode
     * </pre>
     *
     * where "arguments" is the empty or non-empty string of the arguments to pass to the CLI executable,
     * regex is a empty or non-empty regular expresssion string to match the output (if there is a matching
     * group in the regex, its value will be used as the metric value, not the full output of the executable),
     * exitcode is the literal string "exitcode" to indicate that the exit code value is to be used as the metric value.
     *
     * @param metricPropertyName the name of the property in the metric descriptor
     * @return map containing the pieces that have been parsed out - the keys are
     *             either {@link #METRIC_PROPERTY_ARGUMENTS} or {@link #METRIC_PROPERTY_REGEX}.
     *             The map will be <code>null</code> if the property name is invalid for some reason.
     *             A map entry will not exist if it was not specified in the property name.
     */
    protected Map<String, String> parseMetricProperty(String metricPropertyName) {
        HashMap<String, String> map = new HashMap<String, String>();

        if (metricPropertyName != null && metricPropertyName.length() > 0) {
            if (!metricPropertyName.startsWith("{")) {
                map.put(METRIC_PROPERTY_ARGUMENTS, metricPropertyName); // the property is entirely the arguments 
            } else {
                String[] argsRegex = metricPropertyName.substring(1).split("\\}\\|", 2);
                if (argsRegex.length != 2) {
                    log.error("Invalid metric property [" + metricPropertyName + "] - metric will not be collected");
                    return null;
                }
                if (!isValidRegularExpression(argsRegex[1])) {
                    log.error("Invalid regex [" + argsRegex[1] + "] for metric property [" + metricPropertyName
                        + "] - metric will not be collected");
                    return null;
                }
                if (argsRegex[0].length() > 0) {
                    map.put(METRIC_PROPERTY_ARGUMENTS, argsRegex[0]);
                }
                if (argsRegex[1].length() > 0) {
                    map.put(METRIC_PROPERTY_REGEX, argsRegex[1]);
                }
            }
        }

        return map;
    }

    /**
     * Invokes the CLI executable. User can tell us what arguments to pass to the executable.
     * The result includes the output of the process as well as the exit code.
     * 
     * @see OperationFacet#invokeOperation(String, Configuration)
     */
    public OperationResult invokeOperation(String name, Configuration configuration) throws Exception {

        OperationResult result = new OperationResult();

        String arguments = configuration.getSimpleValue(OPERATION_PARAM_ARGUMENTS, null);

        ProcessExecutionResults exeResults = executeExecutable(arguments, MAX_WAIT_TIME, true);
        Integer exitcode = exeResults.getExitCode();
        String output = exeResults.getCapturedOutput();
        Throwable error = exeResults.getError();

        if (exitcode == null) {
            exitcode = Integer.valueOf(-999);
        }

        if (output == null) {
            output = "";
        }

        if (error != null) {
            result.setErrorMessage(ThrowableUtil.getAllMessages(error));
        }

        Configuration resultsConfig = result.getComplexResults();
        resultsConfig.put(new PropertySimple(OPERATION_RESULT_EXITCODE, exitcode));
        resultsConfig.put(new PropertySimple(OPERATION_RESULT_OUTPUT, output));

        return result;
    }

    protected ResourceContext getResourcContext() {
        return this.resourceContext;
    }

    /**
     * Executes the CLI executable with the given arguments.
     * 
     * @param args the arguments to send to the executable (may be <code>null</code>)
     * @param wait the maximum time in milliseconds to wait for the process to execute; 0 means do not wait 
     * @param captureOutput if <code>true</code>, the executables output will be captured and returned
     *
     * @return the results of the execution
     *
     * @throws InvalidPluginConfigurationException
     */
    protected ProcessExecutionResults executeExecutable(String args, long wait, boolean captureOutput)
        throws InvalidPluginConfigurationException {

        SystemInfo sysInfo = this.resourceContext.getSystemInformation();
        Configuration pluginConfig = this.resourceContext.getPluginConfiguration();
        ProcessExecutionResults results = executeExecutable(sysInfo, pluginConfig, args, wait, captureOutput);

        if (log.isDebugEnabled()) {
            if (results != null) {
                logDebug("CLI results: exitcode=[" + results.getExitCode() + "]; error=[" + results.getError()
                    + "]; output=" + truncateString(results.getCapturedOutput()));
            } else {
                logDebug("CLI has null results");
            }
        }

        return results;
    }

    // This is protected static so the discovery component can use it.
    protected static ProcessExecutionResults executeExecutable(SystemInfo sysInfo, Configuration pluginConfig,
        String args, long wait, boolean captureOutput) throws InvalidPluginConfigurationException {

        ProcessExecution processExecution = getProcessExecutionInfo(pluginConfig);
        if (args != null) {
            processExecution.setArguments(args.split(" "));
        }
        processExecution.setCaptureOutput(captureOutput);
        processExecution.setWaitForCompletion(wait);
        processExecution.setKillOnTimeout(true);

        ProcessExecutionResults results = sysInfo.executeProcess(processExecution);

        return results;
    }

    private boolean checkAvailability() throws InvalidPluginConfigurationException {

        String executable;
        boolean availExecuteCheck = false;
        String availArgs = null;
        String availExitCodeRegex = null;
        String availOutputRegex = null;

        // determine how we are to consider the CLI available by looking at the plugin configuration
        try {
            Configuration pc = this.resourceContext.getPluginConfiguration();

            executable = pc.getSimpleValue(PLUGINCONFIG_EXECUTABLE, null);
            if (executable == null) {
                throw new Exception("Missing executable in plugin configuraton");
            }

            PropertySimple availExecuteCheckProp = pc.getSimple(PLUGINCONFIG_AVAIL_EXECUTE_CHECK);
            PropertySimple availArgsProp = pc.getSimple(PLUGINCONFIG_AVAIL_ARGS);
            PropertySimple availExitCodeRegexProp = pc.getSimple(PLUGINCONFIG_AVAIL_EXITCODE_REGEX);
            PropertySimple availOutputRegexProp = pc.getSimple(PLUGINCONFIG_AVAIL_OUTPUT_REGEX);

            if (availExecuteCheckProp != null && availExecuteCheckProp.getBooleanValue() != null) {
                availExecuteCheck = availExecuteCheckProp.getBooleanValue().booleanValue();
            }

            if (availArgsProp != null && availArgsProp.getStringValue() != null) {
                availArgs = availArgsProp.getStringValue();
            }

            if (availExitCodeRegexProp != null && availExitCodeRegexProp.getStringValue() != null) {
                availExitCodeRegex = availExitCodeRegexProp.getStringValue();
            }

            if (availOutputRegexProp != null && availOutputRegexProp.getStringValue() != null) {
                availOutputRegex = availOutputRegexProp.getStringValue();
            }
        } catch (Exception e) {
            throw new InvalidPluginConfigurationException("Cannot get avail plugin config. Cause: " + e);
        }

        // first, make sure the executable actually exists
        File executableFile = new File(executable);
        if (!executableFile.exists()) {
            if (log.isDebugEnabled()) {
                logDebug("The executable [" + executable + "] does not exist - resource is considered DOWN");
            }
            return false;
        }

        // if we need to check the exit code or output, execute the CLI now
        if (availExecuteCheck || availExitCodeRegex != null || availOutputRegex != null) {
            ProcessExecutionResults results = executeExecutable(availArgs, 3000L, (availOutputRegex != null));

            // if we get some error while trying to run the executable, immediately consider the resource down
            if (results.getError() != null) {
                if (log.isDebugEnabled()) {
                    logDebug("CLI execution encountered an error, resource is considered DOWN: "
                        + ThrowableUtil.getAllMessages(results.getError()));
                }
                return false;
            }

            // if the exit code is used to determine availability, check it now
            if (availExitCodeRegex != null) {
                if (results.getExitCode() == null) {
                    if (log.isDebugEnabled()) {
                        logDebug("Cannot get exit code, resource is considered DOWN");
                    }
                    return false;
                }

                boolean exitcodeMatches = results.getExitCode().toString().matches(availExitCodeRegex);
                if (!exitcodeMatches) {
                    if (log.isDebugEnabled()) {
                        logDebug("CLI exit code=[" + results.getExitCode() + "] != [" + availExitCodeRegex + "]. DOWN");
                    }
                    return false;
                }
            }

            // if the output is used to determine availability, check it now
            if (availOutputRegex != null) {
                String output = results.getCapturedOutput();
                if (output == null) {
                    output = "";
                }

                boolean outputMatches = output.matches(availOutputRegex);
                if (!outputMatches) {
                    if (log.isDebugEnabled()) {
                        logDebug("CLI output [" + truncateString(output) + "] did not match regex [" + availOutputRegex
                            + "], resource is considered DOWN");
                    }
                    return false;
                }
            }
        }

        // everything passes, resource is UP
        return true;
    }

    /**
     * Truncate a string so it is short, usually for display or logging purposes.
     * 
     * @param output the output to trim
     * @return the trimmed output
     */
    private String truncateString(String output) {
        String outputToLog = output;
        if (outputToLog != null && outputToLog.length() > 100) {
            outputToLog = outputToLog.substring(0, 100) + "...";
        }
        return outputToLog;
    }

    private static ProcessExecution getProcessExecutionInfo(Configuration pluginConfig)
        throws InvalidPluginConfigurationException {

        PropertySimple executableProp = pluginConfig.getSimple(PLUGINCONFIG_EXECUTABLE);
        PropertySimple workingDirProp = pluginConfig.getSimple(PLUGINCONFIG_WORKINGDIR);
        PropertyList envvarsProp = pluginConfig.getList(PLUGINCONFIG_ENVVARS);

        String executable = null;
        String workingDir = null;
        Map<String, String> envvars = null;

        if (executableProp == null) {
            throw new InvalidPluginConfigurationException("Missing required plugin config: " + PLUGINCONFIG_EXECUTABLE);
        } else {
            executable = executableProp.getStringValue();
            if (executable == null || executable.length() == 0) {
                throw new InvalidPluginConfigurationException("Bad plugin config: " + PLUGINCONFIG_EXECUTABLE);
            }
        }

        if (workingDirProp != null) {
            workingDir = workingDirProp.getStringValue();
            if (workingDir != null && workingDir.length() == 0) {
                workingDir = null; // empty string is as good as unsetting it (i.e. making it null)
            }
        }

        if (envvarsProp != null) {
            try {
                // we want envvars to be null if there are no envvars defined, so the agent env is passed
                // but if there are 1 or more envvars in our config, then we define our envvars list
                List<Property> listOfMaps = envvarsProp.getList();
                if (listOfMaps != null && listOfMaps.size() > 0) {
                    envvars = new HashMap<String, String>();
                    for (Property envvarMap : listOfMaps) {
                        PropertySimple name = (PropertySimple) ((PropertyMap) envvarMap).get(PLUGINCONFIG_ENVVAR_NAME);
                        PropertySimple value = (PropertySimple) ((PropertyMap) envvarMap)
                            .get(PLUGINCONFIG_ENVVAR_VALUE);
                        envvars.put(name.getStringValue(), value.getStringValue());
                    }
                }
            } catch (Exception e) {
                throw new InvalidPluginConfigurationException("Bad plugin config: " + PLUGINCONFIG_ENVVARS
                    + ". Cause: " + e);
            }
        }

        ProcessExecution processExecution = new ProcessExecution(executable);
        processExecution.setEnvironmentVariables(envvars);
        processExecution.setWorkingDirectory(workingDir);

        return processExecution;
    }

    private boolean isValidRegularExpression(String regex) {
        try {
            Pattern.compile(regex);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    private void logDebug(String msg) {
        log.debug("[" + this.resourceContext.getResourceKey() + "]: " + msg);
    }
}
