/*
 * Copyright 2014 the original author or authors.
 *
 * 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.gradle.model.dsl.internal.transform;

import com.google.common.base.Joiner;
import org.codehaus.groovy.ast.ASTNode;
import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.ClassNode;
import org.codehaus.groovy.ast.FieldNode;
import org.codehaus.groovy.ast.MethodNode;
import org.codehaus.groovy.ast.Parameter;
import org.codehaus.groovy.ast.VariableScope;
import org.codehaus.groovy.ast.expr.ArgumentListExpression;
import org.codehaus.groovy.ast.expr.BinaryExpression;
import org.codehaus.groovy.ast.expr.ClosureExpression;
import org.codehaus.groovy.ast.expr.ConstantExpression;
import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
import org.codehaus.groovy.ast.expr.DeclarationExpression;
import org.codehaus.groovy.ast.expr.ElvisOperatorExpression;
import org.codehaus.groovy.ast.expr.Expression;
import org.codehaus.groovy.ast.expr.FieldExpression;
import org.codehaus.groovy.ast.expr.MethodCallExpression;
import org.codehaus.groovy.ast.expr.PropertyExpression;
import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
import org.codehaus.groovy.ast.expr.VariableExpression;
import org.codehaus.groovy.ast.stmt.BlockStatement;
import org.codehaus.groovy.ast.stmt.ExpressionStatement;
import org.codehaus.groovy.ast.stmt.ReturnStatement;
import org.codehaus.groovy.ast.stmt.Statement;
import org.codehaus.groovy.control.SourceUnit;
import org.codehaus.groovy.syntax.SyntaxException;
import org.codehaus.groovy.syntax.Token;
import org.codehaus.groovy.syntax.Types;
import org.gradle.groovy.scripts.internal.AstUtils;
import org.gradle.groovy.scripts.internal.ExpressionReplacingVisitorSupport;
import org.gradle.internal.SystemProperties;
import org.gradle.model.dsl.internal.inputs.PotentialInputs;
import org.gradle.model.internal.core.ModelPath;
import org.gradle.util.internal.CollectionUtils;

import java.lang.reflect.Modifier;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class RuleVisitor extends ExpressionReplacingVisitorSupport {

    public static final String INVALID_ARGUMENT_LIST = "argument list must be exactly 1 literal non empty string";
    public static final String SOURCE_URI_TOKEN = "@@sourceuri@@";
    public static final String SOURCE_DESC_TOKEN = "@@sourcedesc@@";

    private static final String AST_NODE_METADATA_INPUTS_KEY = RuleVisitor.class.getName() + ".inputs";
    private static final String AST_NODE_METADATA_LOCATION_KEY = RuleVisitor.class.getName() + ".location";

    private static final String DOLLAR = "$";
    private static final String GET = "get";
    private static final ClassNode POTENTIAL_INPUTS = new ClassNode(PotentialInputs.class);
    private static final ClassNode TRANSFORMED_CLOSURE = new ClassNode(TransformedClosure.class);
    private static final ClassNode INPUT_REFERENCES = new ClassNode(InputReferences.class);
    private static final ClassNode SOURCE_LOCATION = new ClassNode(SourceLocation.class);
    private static final ClassNode RULE_FACTORY = new ClassNode(ClosureBackedRuleFactory.class);

    private static final String INPUTS_FIELD_NAME = "__inputs__";
    private static final String RULE_FACTORY_FIELD_NAME = "__rule_factory__";
    private static final Token ASSIGN = new Token(Types.ASSIGN, "=", -1, -1);

    private final String scriptSourceDescription;
    private final URI location;
    private final SourceUnit sourceUnit;
    private InputReferences inputs;
    private VariableExpression inputsVariable;
    private int nestingDepth;
    private int counter;

    public RuleVisitor(SourceUnit sourceUnit, String scriptSourceDescription, URI location) {
        this.scriptSourceDescription = scriptSourceDescription;
        this.location = location;
        this.sourceUnit = sourceUnit;
    }

    // Not part of a normal visitor, see ClosureCreationInterceptingVerifier
    public static void visitGeneratedClosure(ClassNode node) {
        MethodNode closureCallMethod = AstUtils.getGeneratedClosureImplMethod(node);
        Statement closureCode = closureCallMethod.getCode();
        InputReferences inputs = closureCode.getNodeMetaData(AST_NODE_METADATA_INPUTS_KEY);
        if (inputs != null) {
            SourceLocation sourceLocation = closureCode.getNodeMetaData(AST_NODE_METADATA_LOCATION_KEY);
            node.addInterface(TRANSFORMED_CLOSURE);
            FieldNode inputsField = new FieldNode(INPUTS_FIELD_NAME, Modifier.PRIVATE, POTENTIAL_INPUTS, node, null);
            FieldNode ruleFactoryField = new FieldNode(RULE_FACTORY_FIELD_NAME, Modifier.PRIVATE, RULE_FACTORY, node, null);
            node.addField(inputsField);
            node.addField(ruleFactoryField);

            // Generate makeRule() method
            List<Statement> statements = new ArrayList<Statement>();
            statements.add(new ExpressionStatement(new BinaryExpression(new FieldExpression(inputsField), ASSIGN, new VariableExpression("inputs"))));
            statements.add(new ExpressionStatement(new BinaryExpression(new FieldExpression(ruleFactoryField), ASSIGN, new VariableExpression("ruleFactory"))));
            node.addMethod(new MethodNode("makeRule",
                    Modifier.PUBLIC,
                    ClassHelper.VOID_TYPE,
                    new Parameter[]{new Parameter(POTENTIAL_INPUTS, "inputs"), new Parameter(RULE_FACTORY, "ruleFactory")},
                    new ClassNode[0],
                    new BlockStatement(statements, new VariableScope())));

            // Generate inputReferences() method
            VariableExpression inputsVar = new VariableExpression("inputs", INPUT_REFERENCES);
            VariableScope methodVarScope = new VariableScope();
            methodVarScope.putDeclaredVariable(inputsVar);
            statements = new ArrayList<Statement>();
            statements.add(new ExpressionStatement(new DeclarationExpression(inputsVar, ASSIGN, new ConstructorCallExpression(INPUT_REFERENCES, new ArgumentListExpression()))));
            for (InputReference inputReference : inputs.getOwnReferences()) {
                statements.add(new ExpressionStatement(new MethodCallExpression(inputsVar,
                        "ownReference",
                        new ArgumentListExpression(
                                new ConstantExpression(inputReference.getPath()),
                                new ConstantExpression(inputReference.getLineNumber())))));
            }
            for (InputReference inputReference : inputs.getNestedReferences()) {
                statements.add(new ExpressionStatement(new MethodCallExpression(inputsVar,
                        "nestedReference",
                        new ArgumentListExpression(
                                new ConstantExpression(inputReference.getPath()),
                                new ConstantExpression(inputReference.getLineNumber())))));
            }
            statements.add(new ReturnStatement(inputsVar));
            node.addMethod(new MethodNode("inputReferences",
                                Modifier.PUBLIC,
                                INPUT_REFERENCES,
                                new Parameter[0],
                                new ClassNode[0],
                                new BlockStatement(statements, methodVarScope)));

            // Generate sourceLocation() method
            statements = new ArrayList<Statement>();
            statements.add(new ReturnStatement(new ConstructorCallExpression(SOURCE_LOCATION,
                    new ArgumentListExpression(Arrays.<Expression>asList(
                            new ConstantExpression(SOURCE_URI_TOKEN),
                            new ConstantExpression(SOURCE_DESC_TOKEN),
                            new ConstantExpression(sourceLocation.getExpression()),
                            new ConstantExpression(sourceLocation.getLineNumber()),
                            new ConstantExpression(sourceLocation.getColumnNumber())
                    )))));
            node.addMethod(new MethodNode("sourceLocation",
                                Modifier.PUBLIC,
                                SOURCE_LOCATION,
                                new Parameter[0],
                                new ClassNode[0],
                                new BlockStatement(statements, new VariableScope())));
        }
    }

    public void visitRuleClosure(ClosureExpression expression, Expression invocation, String invocationDisplayName) {
        InputReferences parentInputs = inputs;
        VariableExpression parentInputsVariable = inputsVariable;
        try {
            inputs = new InputReferences();
            inputsVariable = new VariableExpression("__rule_inputs_var_" + (counter++), POTENTIAL_INPUTS);
            inputsVariable.setClosureSharedVariable(true);
            super.visitClosureExpression(expression);
            BlockStatement code = (BlockStatement) expression.getCode();
            code.setNodeMetaData(AST_NODE_METADATA_LOCATION_KEY, new SourceLocation(location, scriptSourceDescription, invocationDisplayName, invocation.getLineNumber(), invocation.getColumnNumber()));
            code.setNodeMetaData(AST_NODE_METADATA_INPUTS_KEY, inputs);
            if (parentInputsVariable != null) {
                expression.getVariableScope().putReferencedLocalVariable(parentInputsVariable);
            }
            code.getVariableScope().putDeclaredVariable(inputsVariable);

            if (parentInputsVariable == null) {
                // <inputs-lvar> = <inputs-field>
                DeclarationExpression variableDeclaration = new DeclarationExpression(inputsVariable, ASSIGN, new VariableExpression(INPUTS_FIELD_NAME));
                code.getStatements().add(0, new ExpressionStatement(variableDeclaration));

            } else {
                // <inputs-lvar> = <inputs-field> ?: <parent-inputs-lvar>
                DeclarationExpression variableDeclaration = new DeclarationExpression(inputsVariable, ASSIGN,
                        new ElvisOperatorExpression(
                                new VariableExpression(INPUTS_FIELD_NAME),
                                parentInputsVariable));
                code.getStatements().add(0, new ExpressionStatement(variableDeclaration));
            }

            // Move default values into body of closure, so they can use <inputs-lvar>
            for (Parameter parameter : expression.getParameters()) {
                if (parameter.hasInitialExpression()) {
                    code.getStatements().add(1, new ExpressionStatement(new BinaryExpression(new VariableExpression(parameter.getName()), ASSIGN, parameter.getInitialExpression())));
                    parameter.setInitialExpression(ConstantExpression.NULL);
                }
            }
        } finally {
            if (parentInputs != null) {
                parentInputs.addNestedReferences(inputs);
            }
            inputs = parentInputs;
            inputsVariable = parentInputsVariable;
        }
    }

    @Override
    public void visitClosureExpression(ClosureExpression expression) {
        // Nested closure
        nestingDepth++;
        try {
            expression.getVariableScope().putReferencedLocalVariable(inputsVariable);
            super.visitClosureExpression(expression);
        } finally {
            nestingDepth--;
        }
    }

    @Override
    public void visitPropertyExpression(PropertyExpression expr) {
        String modelPath = isDollarPathExpression(expr);
        if (modelPath != null) {
            inputs.ownReference(modelPath, expr.getLineNumber());
            replaceVisitedExpressionWith(inputReferenceExpression(modelPath));
        } else {
            super.visitPropertyExpression(expr);
        }
    }

    private String isDollarPathExpression(PropertyExpression expr) {
        if (expr.isSafe() || expr.isSpreadSafe()) {
            return null;
        }
        if (expr.getObjectExpression() instanceof VariableExpression) {
            VariableExpression objectExpression = (VariableExpression) expr.getObjectExpression();
            if (objectExpression.getName().equals(DOLLAR)) {
                return expr.getPropertyAsString();
            } else {
                return null;
            }
        }
        if (expr.getObjectExpression() instanceof PropertyExpression) {
            PropertyExpression objectExpression = (PropertyExpression) expr.getObjectExpression();
            String path = isDollarPathExpression(objectExpression);
            if (path != null) {
                return path + '.' + expr.getPropertyAsString();
            } else {
                return null;
            }
        }
        return null;
    }

    @Override
    public void visitExpressionStatement(ExpressionStatement stat) {
        if (nestingDepth == 0 && stat.getExpression() instanceof MethodCallExpression) {
            MethodCallExpression call = (MethodCallExpression) stat.getExpression();
            if (call.isImplicitThis() && call.getArguments() instanceof ArgumentListExpression) {
                ArgumentListExpression arguments = (ArgumentListExpression) call.getArguments();
                if (!arguments.getExpressions().isEmpty()) {
                    Expression lastArg = arguments.getExpression(arguments.getExpressions().size() - 1);
                    if (lastArg instanceof ClosureExpression) {
                        // This is a potential nested rule.
                        // Visit method parameters
                        for (int i = 0; i < arguments.getExpressions().size() - 1; i++) {
                            arguments.getExpressions().set(i, replaceExpr(arguments.getExpression(i)));
                        }
                        // Transform closure
                        ClosureExpression closureExpression = (ClosureExpression) lastArg;
                        visitRuleClosure(closureExpression, call, displayName(call));
                        Expression replaced = new StaticMethodCallExpression(RULE_FACTORY, "decorate", new ArgumentListExpression(new VariableExpression(RULE_FACTORY_FIELD_NAME), closureExpression));
                        arguments.getExpressions().set(arguments.getExpressions().size() - 1, replaced);
                        return;
                    }
                }
            }
        }
        super.visitExpressionStatement(stat);
    }

    @Override
    public void visitMethodCallExpression(MethodCallExpression call) {
        String methodName = call.getMethodAsString();
        if (call.isImplicitThis() && methodName != null && methodName.equals(DOLLAR)) {
            visitInputMethod(call);
            return;
        }
        // visit the method call, because one of the args may be an input method call
        super.visitMethodCallExpression(call);
    }

    private void visitInputMethod(MethodCallExpression call) {
        ConstantExpression argExpression = AstUtils.hasSingleConstantStringArg(call);
        if (argExpression == null) { // not a valid signature
            error(call, INVALID_ARGUMENT_LIST);
        } else {
            String modelPath = argExpression.getText();
            if (modelPath.isEmpty()) {
                error(argExpression, INVALID_ARGUMENT_LIST);
                return;
            }

            try {
                ModelPath.validatePath(modelPath);
            } catch (ModelPath.InvalidPathException e) {
                // TODO find a better way to present this information in the error message
                // Attempt to mimic Gradle nested exception output
                String message = "Invalid model path given as rule input." + SystemProperties.getInstance().getLineSeparator()
                        + "  > " + e.getMessage();
                if (e.getCause() != null) {
                    // if there is a cause, it's an invalid name exception
                    message += SystemProperties.getInstance().getLineSeparator() + "    > " + e.getCause().getMessage();
                }
                error(argExpression, message);
                return;
            }

            inputs.ownReference(modelPath, call.getLineNumber());
            replaceVisitedExpressionWith(inputReferenceExpression(modelPath));
        }
    }

    private MethodCallExpression inputReferenceExpression(String modelPath) {
        return new MethodCallExpression(new VariableExpression(inputsVariable), new ConstantExpression(GET), new ArgumentListExpression(new ConstantExpression(modelPath)));
    }

    private void error(ASTNode call, String message) {
        SyntaxException syntaxException = new SyntaxException(message, call.getLineNumber(), call.getColumnNumber());
        sourceUnit.getErrorCollector().addError(syntaxException, sourceUnit);
    }

    public static String displayName(MethodCallExpression expression) {
        StringBuilder builder = new StringBuilder();
        if (!expression.isImplicitThis()) {
            builder.append(expression.getObjectExpression().getText());
            builder.append('.');
        }
        builder.append(expression.getMethodAsString());
        if (expression.getArguments() instanceof ArgumentListExpression) {
            ArgumentListExpression arguments = (ArgumentListExpression) expression.getArguments();
            boolean hasTrailingClosure = !arguments.getExpressions().isEmpty() && arguments.getExpression(arguments.getExpressions().size() - 1) instanceof ClosureExpression;
            List<Expression> otherArgs = hasTrailingClosure ? arguments.getExpressions().subList(0, arguments.getExpressions().size() - 1) : arguments.getExpressions();
            if (!otherArgs.isEmpty() || !hasTrailingClosure) {
                builder.append("(");
                builder.append(Joiner.on(", ").join(CollectionUtils.collect(otherArgs, ASTNode::getText)));
                builder.append(")");
            }
            if (hasTrailingClosure) {
                builder.append(" { ... }");
            }
        } else {
            builder.append("()");
        }
        return builder.toString();
    }
}
