package org.jbehave.core.reporters;

import static org.jbehave.core.steps.StepCreator.PARAMETER_TABLE_END;
import static org.jbehave.core.steps.StepCreator.PARAMETER_TABLE_START;
import static org.jbehave.core.steps.StepCreator.PARAMETER_VALUE_END;
import static org.jbehave.core.steps.StepCreator.PARAMETER_VALUE_START;
import static org.jbehave.core.steps.StepCreator.PARAMETER_VERBATIM_END;
import static org.jbehave.core.steps.StepCreator.PARAMETER_VERBATIM_START;

import java.io.File;
import java.io.FileWriter;
import java.io.Writer;
import java.text.MessageFormat;
import java.util.ArrayList;
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.lang3.StringUtils;
import org.jbehave.core.annotations.AfterScenario.Outcome;
import org.jbehave.core.annotations.Scope;
import org.jbehave.core.configuration.Keywords;
import org.jbehave.core.embedder.MetaFilter;
import org.jbehave.core.model.ExamplesTable;
import org.jbehave.core.model.GivenStories;
import org.jbehave.core.model.Lifecycle;
import org.jbehave.core.model.Meta;
import org.jbehave.core.model.Narrative;
import org.jbehave.core.model.OutcomesTable;
import org.jbehave.core.model.Scenario;
import org.jbehave.core.model.Story;
import org.jbehave.core.model.StoryDuration;
import org.jbehave.core.model.Verbatim;
import org.jbehave.core.steps.StepCollector;
import org.jbehave.core.steps.StepCreator.PendingStep;
import org.jbehave.core.steps.Timing;

import freemarker.ext.beans.BeansWrapper;
import freemarker.template.TemplateHashModel;
import freemarker.template.TemplateModelException;

/**
 * <p>
 * Story reporter that outputs to a template.
 * </p>
 */
public class TemplateableOutput extends NullStoryReporter {

    private final File file;
    private final Keywords keywords;
    private final TemplateProcessor processor;
    private final String templatePath;
    private OutputStory outputStory = new OutputStory();
    private OutputScenario outputScenario = new OutputScenario();
    private OutputStep failedStep;
    private OutputStep pendingStep;
    private Scope scope;
    private StepCollector.Stage stage;

    public TemplateableOutput(File file, Keywords keywords, TemplateProcessor processor, String templatePath) {
        this.file = file;
        this.keywords = keywords;
        this.processor = processor;
        this.templatePath = templatePath;
    }

    @Override
    public void storyExcluded(Story story, String filter) {
        this.outputStory.excludedBy = filter;
    }

    @Override
    public void beforeStory(Story story, boolean givenStory) {
        if (!givenStory) {
            this.outputStory = new OutputStory();
            this.outputStory.description = story.getDescription().asString();
            this.outputStory.path = story.getPath();
            this.scope = Scope.STORY;
            this.stage = StepCollector.Stage.BEFORE;
        }
        if (!story.getMeta().isEmpty()) {
            this.outputStory.meta = new OutputMeta(story.getMeta());
        }
    }

    @Override
    public void narrative(Narrative narrative) {
        if (!narrative.isEmpty()) {
            this.outputStory.narrative = new OutputNarrative(narrative);
        }
    }

    @Override
    public void lifecycle(Lifecycle lifecycle) {
        if (!lifecycle.isEmpty()) {
            this.outputStory.lifecycle = new OutputLifecycle(lifecycle);            
        }
    }

    @Override
    public void scenarioExcluded(Scenario scenario, String filter) {
        this.outputScenario.excludedBy = filter;
    }

    @Override
    public void beforeScenario(Scenario scenario) {
        if (this.outputScenario.currentExample == null) {
            this.outputScenario = new OutputScenario();
        }
        this.outputScenario.title = scenario.getTitle();
        this.scope = Scope.SCENARIO;

        Meta meta = scenario.getMeta();
        if (!meta.isEmpty()) {
            this.outputScenario.meta = new OutputMeta(meta);
        }
    }

    private void addStep(OutputStep outputStep) {
        if (scope == Scope.STORY) {
            if (stage == StepCollector.Stage.BEFORE) {
                this.outputStory.addBeforeStep(outputStep);
            } else {
                this.outputStory.addAfterStep(outputStep);
            }
        } else {
            this.outputScenario.addStep(outputStep);
        }
    }

    @Override
    public void successful(String step) {
        addStep(new OutputStep(step, "successful"));
    }

    @Override
    public void ignorable(String step) {
        addStep(new OutputStep(step, "ignorable"));
    }

    @Override
    public void comment(String step) {
        addStep(new OutputStep(step, "comment"));
    }

    @Override
    public void pending(PendingStep step) {
        this.pendingStep = new OutputStep(step.stepAsString(), "pending");
        pendingStep.pendingMethod = step.getPendingMethod();
        addStep(pendingStep);
    }

    @Override
    public void notPerformed(String step) {
        addStep(new OutputStep(step, "notPerformed"));
    }

    @Override
    public void failed(String step, Throwable storyFailure) {
        this.failedStep = new OutputStep(step, "failed");
        failedStep.failure = storyFailure;
        addStep(failedStep);
    }

    @Override
    public void failedOutcomes(String step, OutcomesTable table) {
        failed(step, table.failureCause());
        this.failedStep.outcomes = table;
    }

    @Override
    public void givenStories(GivenStories givenStories) {
        if (!givenStories.getStories().isEmpty()) {
            this.outputScenario.givenStories = givenStories;
        }
    }

    @Override
    public void givenStories(List<String> storyPaths) {
        givenStories(new GivenStories(StringUtils.join(storyPaths, ",")));
    }

    @Override
    public void beforeExamples(List<String> steps, ExamplesTable table) {
        this.outputScenario.examplesSteps = steps;
        this.outputScenario.examplesTable = table;
    }

    @Override
    public void example(Map<String, String> parameters, int exampleIndex) {
        this.outputScenario.examples.add(parameters);
        this.outputScenario.currentExample = parameters;
    }

    @Override
    public void afterExamples() {
        this.outputScenario.currentExample = null;
    }

    @Override
    public void afterScenario(Timing timing) {
        if (this.outputScenario.currentExample == null) {
            this.outputStory.scenarios.add(outputScenario);
        }
        this.scope = Scope.STORY;
        this.stage = StepCollector.Stage.AFTER;
    }

    @Override
    public void pendingMethods(List<String> methods) {
        this.outputStory.pendingMethods = methods;
    }

    @Override
    public void restarted(String step, Throwable cause) {
        addStep(new OutputRestart(step, cause.getMessage()));
    }
    
    @Override
    public void restartedStory(Story story, Throwable cause) {
        addStep(new OutputRestart(story.getName(), cause.getMessage()));
    }

    @Override
    public void storyCancelled(Story story, StoryDuration storyDuration) {
        this.outputStory.cancelled = true;
        this.outputStory.storyDuration = storyDuration;
    }

    @Override
    public void afterStory(boolean givenStory) {
        if (!givenStory) {
            Map<String, Object> model = newDataModel();
            model.put("story", outputStory);
            model.put("keywords", new OutputKeywords(keywords));

            TemplateHashModel enumModels = BeansWrapper.getDefaultInstance().getEnumModels();
            TemplateHashModel escapeEnums;
            try {
                String escapeModeEnum = EscapeMode.class.getCanonicalName();
                escapeEnums = (TemplateHashModel) enumModels.get(escapeModeEnum);
                model.put("EscapeMode", escapeEnums);  
            } catch (TemplateModelException e) {
                throw new IllegalArgumentException(e);
            }  

            write(file, templatePath, model);
        }
    }

    private File write(File file, String resource, Map<String, Object> dataModel) {
        try {
            file.getParentFile().mkdirs();
            Writer writer = new FileWriter(file);
            processor.process(resource, dataModel, writer);
            writer.close();
            return file;
        } catch (Exception e) {
            throw new RuntimeException(resource, e);
        }
    }

    private Map<String, Object> newDataModel() {
        return new HashMap<>();
    }

    public static class OutputKeywords {

        private final Keywords keywords;

        public OutputKeywords(Keywords keywords) {
            this.keywords = keywords;
        }

        public String getLifecycle() {
            return keywords.lifecycle();
        }

        public String getScope() {
            return keywords.scope();
        }

        public String getScopeScenario() {
            return keywords.scopeScenario();
        }

        public String getScopeStory() {
            return keywords.scopeStory();
        }

        public String getBefore() {
            return keywords.before();
        }

        public String getAfter() {
            return keywords.after();
        }

        public String getMeta() {
            return keywords.meta();
        }

        public String getMetaProperty() {
            return keywords.metaProperty();
        }

        public String getNarrative() {
            return keywords.narrative();
        }

        public String getInOrderTo() {
            return keywords.inOrderTo();
        }

        public String getAsA() {
            return keywords.asA();
        }

        public String getiWantTo() {
            return keywords.iWantTo();
        }

        public String getSoThat() {
            return keywords.soThat();
        }

        public String getScenario() {
            return keywords.scenario();
        }

        public String getGivenStories() {
            return keywords.givenStories();
        }

        public String getExamplesTable() {
            return keywords.examplesTable();
        }

        public String getExamplesTableRow() {
            return keywords.examplesTableRow();
        }

        public String getExamplesTableHeaderSeparator() {
            return keywords.examplesTableHeaderSeparator();
        }

        public String getExamplesTableValueSeparator() {
            return keywords.examplesTableValueSeparator();
        }

        public String getExamplesTableIgnorableSeparator() {
            return keywords.examplesTableIgnorableSeparator();
        }

        public String getGiven() {
            return keywords.given();
        }

        public String getWhen() {
            return keywords.when();
        }

        public String getThen() {
            return keywords.then();
        }

        public String getAnd() {
            return keywords.and();
        }

        public String getIgnorable() {
            return keywords.ignorable();
        }

        public String getPending() {
            return keywords.pending();
        }

        public String getNotPerformed() {
            return keywords.notPerformed();
        }

        public String getFailed() {
            return keywords.failed();
        }

        public String getDryRun() {
            return keywords.dryRun();
        }

        public String getStoryCancelled() {
            return keywords.storyCancelled();
        }

        public String getDuration() {
            return keywords.duration();
        }

        public String getOutcome() {
            return keywords.outcome();
        }
        
        public String getMetaFilter() {
            return keywords.metaFilter();
        }
        
        public String getYes() {
            return keywords.yes();
        }

        public String getNo() {
            return keywords.no();
        }
    }

    public static class OutputStory {
        private String description;
        private String path;
        private OutputMeta meta;
        private OutputNarrative narrative;
        private OutputLifecycle lifecycle;
        private String excludedBy;
        private List<String> pendingMethods;
        private List<OutputStep> beforeSteps = new ArrayList<>();
        private List<OutputStep> afterSteps = new ArrayList<>();
        private List<OutputScenario> scenarios = new ArrayList<>();
        private boolean cancelled;
        private StoryDuration storyDuration;

        public String getDescription() {
            return description;
        }

        public String getPath() {
            return path;
        }

        public OutputMeta getMeta() {
            return meta;
        }

        public OutputNarrative getNarrative() {
            return narrative;
        }

        public OutputLifecycle getLifecycle() {
            return lifecycle;
        }

        public String getExcludedBy() {
            return excludedBy;
        }

        public void addBeforeStep(OutputStep outputStep) {
            this.beforeSteps.add(outputStep);
        }

        public void addAfterStep(OutputStep outputStep) {
            this.afterSteps.add(outputStep);
        }

        public List<OutputStep> getBeforeSteps() {
            return beforeSteps;
        }

        public List<OutputStep> getAfterSteps() {
            return afterSteps;
        }

        public List<String> getPendingMethods() {
            return pendingMethods;
        }

        public List<OutputScenario> getScenarios() {
            return scenarios;
        }

        public boolean isCancelled() {
            return cancelled;
        }

        public StoryDuration getStoryDuration() {
            return storyDuration;
        }
    }

    public static class OutputMeta {

        private final Meta meta;

        public OutputMeta(Meta meta) {
            this.meta = meta;
        }

        public Map<String, String> getProperties() {
            Map<String, String> properties = new HashMap<>();
            for (String name : meta.getPropertyNames()) {
                properties.put(name, meta.getProperty(name));
            }
            return properties;
        }

    }

    public static class OutputNarrative {
        private final Narrative narrative;

        public OutputNarrative(Narrative narrative) {
            this.narrative = narrative;
        }

        public String getInOrderTo() {
            return narrative.inOrderTo();
        }

        public String getAsA() {
            return narrative.asA();
        }

        public String getiWantTo() {
            return narrative.iWantTo();
        }
        
        public String getSoThat() {
            return narrative.soThat();
        }
        
        public boolean isAlternative() {
            return narrative.isAlternative();
        }

    }

    public static class OutputLifecycle {
        private final Lifecycle lifecycle;

        public OutputLifecycle(Lifecycle lifecycle) {
            this.lifecycle = lifecycle;
        }

        public Set<Scope> getScopes() {
            return lifecycle.getScopes();
        }

        public boolean hasBeforeSteps() {
            return lifecycle.hasBeforeSteps();
        }

        public List<String> getBeforeSteps() {
            return lifecycle.getBeforeSteps();
        }

        public List<String> getBeforeSteps(Scope scope) {
            return lifecycle.getBeforeSteps(scope);
        }

        public boolean hasAfterSteps() {
            return lifecycle.hasAfterSteps();
        }

        public List<String> getAfterSteps() {
            return lifecycle.getAfterSteps();
        }

        public List<String> getAfterSteps(Scope scope) {
            return lifecycle.getAfterSteps(scope);
        }

        public List<String> getAfterSteps(Scope scope, Outcome outcome) {
            return lifecycle.getAfterSteps(scope, outcome);
        }

        public List<String> getAfterSteps(Outcome outcome) {
            return lifecycle.getAfterSteps(outcome);
        }

        public List<String> getAfterSteps(Outcome outcome, Meta meta) {
            return lifecycle.getAfterSteps(outcome, meta);
        }

        public Set<Outcome> getOutcomes() {
            return lifecycle.getOutcomes();
        }

        public MetaFilter getMetaFilter(Outcome outcome) {
            return lifecycle.getMetaFilter(outcome);
        }

    }

    public static class OutputScenario {
        private String title;
        private List<OutputStep> steps = new ArrayList<>();
        private OutputMeta meta;
        private GivenStories givenStories;
        private String excludedBy;
        private List<String> examplesSteps;
        private ExamplesTable examplesTable;
        private Map<String, String> currentExample;
        private List<Map<String, String>> examples = new ArrayList<>();
        private Map<Map<String, String>, List<OutputStep>> stepsByExample = new HashMap<>();

        public String getTitle() {
            return title;
        }

        public void addStep(OutputStep outputStep) {
            if (examplesTable == null) {
                steps.add(outputStep);
            } else {
                List<OutputStep> currentExampleSteps = stepsByExample.get(currentExample);
                if (currentExampleSteps == null) {
                    currentExampleSteps = new ArrayList<>();
                    stepsByExample.put(currentExample, currentExampleSteps);
                }
                currentExampleSteps.add(outputStep);
            }
        }

        public List<OutputStep> getSteps() {
            return steps;
        }

        public List<OutputStep> getStepsByExample(Map<String, String> example) {
            List<OutputStep> steps = stepsByExample.get(example);
            if (steps == null) {
                return new ArrayList<>();
            }
            return steps;
        }

        public OutputMeta getMeta() {
            return meta;
        }

        public GivenStories getGivenStories() {
            return givenStories;
        }

        public String getExcludedBy() {
            return excludedBy;
        }

        public List<String> getExamplesSteps() {
            return examplesSteps;
        }

        public ExamplesTable getExamplesTable() {
            return examplesTable;
        }

        public List<Map<String, String>> getExamples() {
            return examples;
        }
    }

    public static class OutputRestart extends OutputStep {

        public OutputRestart(String step, String outcome) {
            super(step, outcome);
        }

    }

    public static class OutputStep {
        private final String step;
        private final String outcome;
        private Throwable failure;
        private OutcomesTable outcomes;
        private List<OutputParameter> parameters;
        private String stepPattern;
        private String tableAsString;
        private ExamplesTable table;
        private String verbatimAsString;
        private Verbatim verbatim;
        private String pendingMethod;

        public OutputStep(String step, String outcome) {
            this.step = step;
            this.outcome = outcome;
            parseTableAsString();
            parseVerbatimAsString();
            parseParameters();
            createStepPattern();
        }

        public String getStep() {
            return step;
        }

        public String getStepPattern() {
            return stepPattern;
        }

        public List<OutputParameter> getParameters() {
            return parameters;
        }

        public String getOutcome() {
            return outcome;
        }

        public Throwable getFailure() {
            return failure;
        }

        public String getPendingMethod() {
            return pendingMethod;
        }

        public String getFailureCause() {
            if (failure != null) {
                return new StackTraceFormatter(true).stackTrace(failure);
            }
            return "";
        }

        public ExamplesTable getTable() {
            return table;
        }

        public Verbatim getVerbatim() {
            return verbatim;
        }

        public OutcomesTable getOutcomes() {
            return outcomes;
        }

        public String getOutcomesFailureCause() {
            if (outcomes.failureCause() != null) {
                return new StackTraceFormatter(true).stackTrace(outcomes.failureCause());
            }
            return "";
        }

        public String getFormattedStep(String parameterPattern) {
            return getFormattedStep(EscapeMode.NONE, parameterPattern);
        }

        public String getFormattedStep(EscapeMode escapeMode, String parameterPattern) {
            // note that escaping the stepPattern string only works
            // because placeholders for parameters do not contain
            // special chars (the placeholder is {0} etc)
            String escapedStep = escapeMode.escapeString(stepPattern);
            if (!parameters.isEmpty()) {
                try {
                    return MessageFormat.format(escapedStep, formatParameters(escapeMode, parameterPattern));
                } catch (RuntimeException e) {
                    throw new StepFormattingFailed(stepPattern, parameterPattern, parameters, e);
                }
            }
            return escapedStep;
        }

        private Object[] formatParameters(EscapeMode escapeMode, String parameterPattern) {
            Object[] arguments = new Object[parameters.size()];
            for (int a = 0; a < parameters.size(); a++) {
                arguments[a] = MessageFormat.format(parameterPattern,
                        escapeMode.escapeString(parameters.get(a).getValue()));
            }
            return arguments;
        }

        private void parseParameters() {
            // first, look for parameterized scenarios
            parameters = findParameters(PARAMETER_VALUE_START + PARAMETER_VALUE_START, PARAMETER_VALUE_END
                    + PARAMETER_VALUE_END);
            // second, look for normal scenarios
            if (parameters.isEmpty()) {
                parameters = findParameters(PARAMETER_VALUE_START, PARAMETER_VALUE_END);
            }
        }

        private List<OutputParameter> findParameters(String start, String end) {
            List<OutputParameter> parameters = new ArrayList<>();
            Matcher matcher = Pattern.compile("(" + start + ".*?" + end + ")(\\W|\\Z)",
                    Pattern.DOTALL).matcher(step);
            while (matcher.find()) {
                parameters.add(new OutputParameter(step, matcher.start(), matcher.end()));
            }
            return parameters;
        }

        private void parseTableAsString() {
            if (step.contains(PARAMETER_TABLE_START) && step.contains(PARAMETER_TABLE_END)) {
                tableAsString = StringUtils.substringBetween(step, PARAMETER_TABLE_START, PARAMETER_TABLE_END);
                table = new ExamplesTable(tableAsString);
            }
        }

        private void parseVerbatimAsString() {
            if (step.contains(PARAMETER_VERBATIM_START) && step.contains(PARAMETER_VERBATIM_END)) {
                verbatimAsString = StringUtils.substringBetween(step, PARAMETER_VERBATIM_START, PARAMETER_VERBATIM_END);
                verbatim = new Verbatim(verbatimAsString);
            }
        }

        private void createStepPattern() {
            this.stepPattern = step;
            if (tableAsString != null) {
                this.stepPattern = StringUtils.replaceOnce(stepPattern, PARAMETER_TABLE_START + tableAsString
                        + PARAMETER_TABLE_END, "");
            }
            if (verbatimAsString != null) {
                this.stepPattern = StringUtils.replaceOnce(stepPattern, PARAMETER_VERBATIM_START + verbatimAsString
                        + PARAMETER_VERBATIM_END, "");
            }
            for (int count = 0; count < parameters.size(); count++) {
                String value = parameters.get(count).toString();
                this.stepPattern = stepPattern.replace(value, "{" + count + "}");
            }
        }

        @SuppressWarnings("serial")
        public static class StepFormattingFailed extends RuntimeException {

            public StepFormattingFailed(String stepPattern, String parameterPattern, List<OutputParameter> parameters,
                    RuntimeException cause) {
                super("Failed to format step '" + stepPattern + "' with parameter pattern '" + parameterPattern
                        + "' and parameters: " + parameters, cause);
            }

        }

    }

    public static class OutputParameter {
        private final String parameter;

        public OutputParameter(String pattern, int start, int end) {
            this.parameter = pattern.substring(start, end).trim();
        }

        public String getValue() {
            String value = StringUtils.remove(parameter, PARAMETER_VALUE_START);
            value = StringUtils.remove(value, PARAMETER_VALUE_END);
            return value;
        }

        @Override
        public String toString() {
            return parameter;
        }
    }

}
