/*
 * 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.jmeter.extractor;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import org.apache.commons.lang3.StringUtils;
import org.apache.jmeter.processor.PostProcessor;
import org.apache.jmeter.samplers.SampleResult;
import org.apache.jmeter.testelement.AbstractScopedTestElement;
import org.apache.jmeter.testelement.property.IntegerProperty;
import org.apache.jmeter.threads.JMeterContext;
import org.apache.jmeter.threads.JMeterVariables;
import org.apache.jmeter.util.JMeterUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class HtmlExtractor extends AbstractScopedTestElement implements PostProcessor, Serializable {

    private static final long serialVersionUID = 1L;
    private static final Logger log = LoggerFactory.getLogger(HtmlExtractor.class);

    public static final String EXTRACTOR_JSOUP = "JSOUP"; //$NON-NLS-1$
    public static final String EXTRACTOR_JODD = "JODD"; //$NON-NLS-1$
    public static final String DEFAULT_EXTRACTOR = ""; // $NON-NLS-1$

    private static final String EXPRESSION = "HtmlExtractor.expr"; // $NON-NLS-1$
    private static final String ATTRIBUTE = "HtmlExtractor.attribute"; // $NON-NLS-1$
    private static final String REFNAME = "HtmlExtractor.refname"; // $NON-NLS-1$
    private static final String MATCH_NUMBER = "HtmlExtractor.match_number"; // $NON-NLS-1$
    private static final String DEFAULT = "HtmlExtractor.default"; // $NON-NLS-1$
    private static final String EXTRACTOR_IMPL = "HtmlExtractor.extractor_impl"; // $NON-NLS-1$
    private static final String REF_MATCH_NR = "_matchNr"; // $NON-NLS-1$
    private static final String UNDERSCORE = "_";  // $NON-NLS-1$
    private static final String DEFAULT_EMPTY_VALUE = "HtmlExtractor.default_empty_value"; // $NON-NLS-1$

    private Extractor extractor;

    /**
     * Get the possible extractor implementations
     * @return Array containing the names of the possible extractors.
     */
    public static String[] getImplementations(){
        return new String[]{EXTRACTOR_JSOUP,EXTRACTOR_JODD};
    }

    /**
     * Parses the response data using CSS/JQuery expressions and saving the results
     * into variables for use later in the test.
     *
     * @see org.apache.jmeter.processor.PostProcessor#process()
     */
    @Override
    public void process() {
        JMeterContext context = getThreadContext();
        SampleResult previousResult = context.getPreviousResult();
        if (previousResult == null) {
            return;
        }
        if (log.isDebugEnabled()) {
            log.debug("HtmlExtractor {}: processing result", getName());
        }
        // Fetch some variables
        JMeterVariables vars = context.getVariables();

        String refName = getRefName();
        String expression = getExpression();
        String attribute = getAttribute();
        int matchNumber = getMatchNumber();
        final String defaultValue = getDefaultValue();

        if (defaultValue.length() > 0  || isEmptyDefaultValue()) {
            // Only replace default if it is provided or empty default value is explicitly requested
            vars.put(refName, defaultValue);
        }

        try {
            List<String> matches =
                    extractMatchingStrings(vars, expression, attribute, matchNumber, previousResult);
            int prevCount = 0;
            String prevString = vars.get(refName + REF_MATCH_NR);
            if (prevString != null) {
                vars.remove(refName + REF_MATCH_NR);// ensure old value is not left defined
                try {
                    prevCount = Integer.parseInt(prevString);
                } catch (NumberFormatException nfe) {
                    if (log.isWarnEnabled()) {
                        log.warn("{}: Could not parse number: '{}'.", getName(), prevString);
                    }
                }
            }
            int matchCount=0;// Number of refName_n variable sets to keep
            String match;
            if (matchNumber >= 0) {// Original match behaviour
                match = getCorrectMatch(matches, matchNumber);
                if (match != null) {
                    vars.put(refName, match);
                }
            } else // < 0 means we save all the matches
            {
                matchCount = matches.size();
                vars.put(refName + REF_MATCH_NR, Integer.toString(matchCount));// Save the count
                for (int i = 1; i <= matchCount; i++) {
                    match = getCorrectMatch(matches, i);
                    if (match != null) {
                        final String refNameN = refName + UNDERSCORE + i;
                        vars.put(refNameN, match);
                    }
                }
            }
            // Remove any left-over variables
            for (int i = matchCount + 1; i <= prevCount; i++) {
                final String refNameN = refName + UNDERSCORE + i;
                vars.remove(refNameN);
            }
        } catch (RuntimeException e) {
            if (log.isWarnEnabled()) {
                log.warn("{}: Error while generating result. {}", getName(), e.toString());
            }
        }
    }

    /**
     * Grab the appropriate result from the list.
     *
     * @param matches list of matches
     * @param entry   the entry number in the list
     * @return MatchResult
     */
    private static String getCorrectMatch(List<String> matches, int entry) {
        int matchSize = matches.size();

        if (matchSize <= 0 || entry > matchSize){
            return null;
        }

        if (entry == 0) { // Random match
            return matches.get(JMeterUtils.getRandomInt(matchSize));
        }

        return matches.get(entry - 1);
    }

    private List<String> extractMatchingStrings(
            JMeterVariables vars, String expression, String attribute,
            int matchNumber, SampleResult previousResult) {
        int found = 0;
        List<String> result = new ArrayList<>();
        if (isScopeVariable()){
            String inputString=vars.get(getVariableName());
            if (!StringUtils.isEmpty(inputString)) {
                getExtractorImpl().extract(expression, attribute, matchNumber, inputString, result, found, "-1");
            } else {
                if (inputString==null && log.isWarnEnabled()) {
                    log.warn("No variable '{}' found to process by CSS Selector Extractor '{}', skipping processing",
                            getVariableName(), getName());
                }
                return Collections.emptyList();
            }
        } else {
            List<SampleResult> sampleList = getSampleList(previousResult);
            int i=0;
            for (SampleResult sr : sampleList) {
                String inputString = sr.getResponseDataAsString();
                found = getExtractorImpl().extract(expression, attribute, matchNumber, inputString, result, found,
                        i>0 ? null : Integer.toString(i));
                i++;
                if (matchNumber > 0 && found == matchNumber){// no need to process further
                    break;
                }
            }
        }
        return Collections.unmodifiableList(result);
    }

    /**
     * @param impl Extractor implementation
     * @return Extractor
     */
    public static Extractor getExtractorImpl(String impl) {
        boolean useDefaultExtractor = DEFAULT_EXTRACTOR.equals(impl);
        if (useDefaultExtractor || EXTRACTOR_JSOUP.equals(impl)) {
            return new JSoupExtractor();
        } else if (EXTRACTOR_JODD.equals(impl)) {
            return new JoddExtractor();
        } else {
            throw new IllegalArgumentException("Extractor implementation:"+ impl+" is unknown");
        }
    }

    private Extractor getExtractorImpl() {
        if (extractor == null) {
            extractor = getExtractorImpl(getExtractor());
        }
        return extractor;
    }

    /**
     * Set the extractor. Has to be one of the list that can be obtained by
     * {@link HtmlExtractor#getImplementations()}
     *
     * @param attribute The name of the extractor to be used
     */
    public void setExtractor(String attribute) {
        setProperty(EXTRACTOR_IMPL, attribute);
    }

    /**
     * Get the name of the currently configured extractor
     * @return The name of the extractor currently used
     */
    public String getExtractor() {
        return getPropertyAsString(EXTRACTOR_IMPL);
    }

    public void setAttribute(String attribute) {
        setProperty(ATTRIBUTE, attribute);
    }

    public String getAttribute() {
        return getPropertyAsString(ATTRIBUTE, ""); // $NON-NLS-1$
    }

    public void setExpression(String regex) {
        setProperty(EXPRESSION, regex);
    }

    public String getExpression() {
        return getPropertyAsString(EXPRESSION);
    }

    public void setRefName(String refName) {
        setProperty(REFNAME, refName);
    }

    public String getRefName() {
        return getPropertyAsString(REFNAME);
    }

    /**
     * Set which Match to use. This can be any positive number, indicating the
     * exact match to use, or <code>0</code>, which is interpreted as meaning random.
     *
     * @param matchNumber The number of the match to be used
     */
    public void setMatchNumber(int matchNumber) {
        setProperty(new IntegerProperty(MATCH_NUMBER, matchNumber));
    }

    public void setMatchNumber(String matchNumber) {
        setProperty(MATCH_NUMBER, matchNumber);
    }

    public int getMatchNumber() {
        return getPropertyAsInt(MATCH_NUMBER);
    }

    public String getMatchNumberAsString() {
        return getPropertyAsString(MATCH_NUMBER);
    }

    /**
     * @param defaultValue value of the variable if no matches are found
     */
    public void setDefaultValue(String defaultValue) {
        setProperty(DEFAULT, defaultValue);
    }

    /**
     * @param defaultEmptyValue boolean set value to "" if not found
     */
    public void setDefaultEmptyValue(boolean defaultEmptyValue) {
        setProperty(DEFAULT_EMPTY_VALUE, defaultEmptyValue);
    }

    /**
     * Get the default value for the variable if no matches are found
     * @return The default value for the variable
     */
    public String getDefaultValue() {
        return getPropertyAsString(DEFAULT);
    }

    /**
     * @return boolean set value to "" if not found
     */
    public boolean isEmptyDefaultValue() {
        return getPropertyAsBoolean(DEFAULT_EMPTY_VALUE);
    }
}
