var assert = require("assert");
var sourceMap = require("source-map");
var printComments = require("./comments").printComments;
var linesModule = require("./lines");
var fromString = linesModule.fromString;
var concat = linesModule.concat;
var normalizeOptions = require("./options").normalize;
var getReprinter = require("./patcher").getReprinter;
var types = require("./types");
var namedTypes = types.namedTypes;
var isString = types.builtInTypes.string;
var isObject = types.builtInTypes.object;
var NodePath = types.NodePath;
var util = require("./util");

function PrintResult(code, sourceMap) {
    assert.ok(this instanceof PrintResult);
    isString.assert(code);

    var properties = {
        code: {
            value: code,
            enumerable: true
        }
    };

    if (sourceMap) {
        isObject.assert(sourceMap);

        properties.map = {
            value: sourceMap,
            enumerable: true
        };
    }

    Object.defineProperties(this, properties);
}

var PRp = PrintResult.prototype;
var warnedAboutToString = false;

PRp.toString = function() {
    if (!warnedAboutToString) {
        console.warn(
            "Deprecation warning: recast.print now returns an object with " +
            "a .code property. You appear to be treating the object as a " +
            "string, which might still work but is strongly discouraged."
        );

        warnedAboutToString = true;
    }

    return this.code;
};

var emptyPrintResult = new PrintResult("");

function Printer(originalOptions) {
    assert.ok(this instanceof Printer);

    var explicitTabWidth = originalOptions && originalOptions.tabWidth;
    var options = normalizeOptions(originalOptions);
    assert.notStrictEqual(options, originalOptions);

    // It's common for client code to pass the same options into both
    // recast.parse and recast.print, but the Printer doesn't need (and
    // can be confused by) options.sourceFileName, so we null it out.
    options.sourceFileName = null;

    function printWithComments(path) {
        assert.ok(path instanceof NodePath);
        return printComments(path.node.comments, print(path));
    }

    function print(path, includeComments) {
        if (includeComments)
            return printWithComments(path);

        assert.ok(path instanceof NodePath);

        if (!explicitTabWidth) {
            var oldTabWidth = options.tabWidth;
            var orig = path.node.original;
            var origLoc = orig && orig.loc;
            var origLines = origLoc && origLoc.lines;
            if (origLines) {
                options.tabWidth = origLines.guessTabWidth();
                try {
                    return maybeReprint(path);
                } finally {
                    options.tabWidth = oldTabWidth;
                }
            }
        }

        return maybeReprint(path);
    }

    function maybeReprint(path) {
        var reprinter = getReprinter(path);
        if (reprinter)
            return maybeAddParens(path, reprinter(printRootGenerically));
        return printRootGenerically(path);
    }

    // Print the root node generically, but then resume reprinting its
    // children non-generically.
    function printRootGenerically(path) {
        return genericPrint(path, options, printWithComments);
    }

    // Print the entire AST generically.
    function printGenerically(path) {
        return genericPrint(path, options, printGenerically);
    }

    this.print = function(ast) {
        if (!ast) {
            return emptyPrintResult;
        }

        var path = ast instanceof NodePath ? ast : new NodePath(ast);
        var lines = print(path, true);

        return new PrintResult(
            lines.toString(options),
            util.composeSourceMaps(
                options.inputSourceMap,
                lines.getSourceMap(
                    options.sourceMapName,
                    options.sourceRoot
                )
            )
        );
    };

    this.printGenerically = function(ast) {
        if (!ast) {
            return emptyPrintResult;
        }

        var path = ast instanceof NodePath ? ast : new NodePath(ast);
        var lines = printGenerically(path);

        return new PrintResult(lines.toString(options));
    };
}

exports.Printer = Printer;

function maybeAddParens(path, lines) {
    return path.needsParens() ? concat(["(", lines, ")"]) : lines;
}

function genericPrint(path, options, printPath) {
    assert.ok(path instanceof NodePath);
    return maybeAddParens(path, genericPrintNoParens(path, options, printPath));
}

function genericPrintNoParens(path, options, print) {
    var n = path.value;

    if (!n) {
        return fromString("");
    }

    if (typeof n === "string") {
        return fromString(n, options);
    }

    namedTypes.Node.assert(n);

    switch (n.type) {
    case "File":
        path = path.get("program");
        n = path.node;
        namedTypes.Program.assert(n);

        // intentionally fall through...

    case "Program":
        return maybeAddSemicolon(
            printStatementSequence(path.get("body"), print)
        );

    case "EmptyStatement":
        return fromString("");

    case "ExpressionStatement":
        return concat([print(path.get("expression")), ";"]);

    case "BinaryExpression":
    case "LogicalExpression":
    case "AssignmentExpression":
        return fromString(" ").join([
            print(path.get("left")),
            n.operator,
            print(path.get("right"))
        ]);

    case "MemberExpression":
        var parts = [print(path.get("object"))];

        if (n.computed)
            parts.push("[", print(path.get("property")), "]");
        else
            parts.push(".", print(path.get("property")));

        return concat(parts);

    case "Path":
        return fromString(".").join(n.body);

    case "Identifier":
        return fromString(n.name, options);

    case "SpreadElement":
        return concat(["...", print(path.get("argument"))]);

    case "FunctionDeclaration":
    case "FunctionExpression":
        var parts = [];

        if (n.async)
            parts.push("async ");

        parts.push("function");

        if (n.generator)
            parts.push("*");

        if (n.id)
            parts.push(" ", print(path.get("id")));

        parts.push(
            "(",
            maybeWrapParams(path.get("params"), options, print),
            ") ",
            print(path.get("body")));

        return concat(parts);

    case "ArrowFunctionExpression":
        var parts = [];

        if (n.async)
            parts.push("async ");

        if (n.params.length === 1) {
            parts.push(print(path.get("params", 0)));
        } else {
            parts.push(
                "(",
                maybeWrapParams(path.get("params"), options, print),
                ")"
            );
        }

        parts.push(" => ", print(path.get("body")));

        return concat(parts);

    case "MethodDefinition":
        return printMethod(
            n.kind,
            path.get("key"),
            path.get("value"),
            options,
            print
        );

    case "YieldExpression":
        var parts = ["yield"];

        if (n.delegate)
            parts.push("*");

        if (n.argument)
            parts.push(" ", print(path.get("argument")));

        return concat(parts);

    case "AwaitExpression":
        var parts = ["await"];

        if (n.all)
            parts.push("*");

        if (n.argument)
            parts.push(" ", print(path.get("argument")));

        return concat(parts);

    case "ModuleDeclaration":
        var parts = ["module", print(path.get("id"))];

        if (n.source) {
            assert.ok(!n.body);
            parts.push("from", print(path.get("source")));
        } else {
            parts.push(print(path.get("body")));
        }

        return fromString(" ").join(parts);

    case "ImportSpecifier":
    case "ExportSpecifier":
        var parts = [print(path.get("id"))];

        if (n.name)
            parts.push(" as ", print(path.get("name")));

        return concat(parts);

    case "ExportBatchSpecifier":
        return fromString("*");

    case "ExportDeclaration":
        var parts = ["export"];

        if (n["default"]) {
            parts.push(" default");

        } else if (n.specifiers &&
                   n.specifiers.length > 0) {

            if (n.specifiers.length === 1 &&
                n.specifiers[0].type === "ExportBatchSpecifier") {
                parts.push(" *");
            } else {
                parts.push(
                    " { ",
                    fromString(", ").join(path.get("specifiers").map(print)),
                    " }"
                );
            }

            if (n.source)
                parts.push(" from ", print(path.get("source")));

            parts.push(";");

            return concat(parts);
        }

        parts.push(" ", print(path.get("declaration")), ";");

        return concat(parts);

    case "ImportDeclaration":
        var parts = ["import"];

        if (!(n.specifiers &&
              n.specifiers.length > 0)) {
            parts.push(" ", print(path.get("source")));

        } else if (n.kind === "default") {
            parts.push(
                " ",
                print(path.get("specifiers", 0)),
                " from ",
                print(path.get("source"))
            );

        } else if (n.kind === "named") {
            parts.push(
                " { ",
                fromString(", ").join(path.get("specifiers").map(print)),
                " } from ",
                print(path.get("source"))
            );
        }

        parts.push(";");

        return concat(parts);

    case "BlockStatement":
        var naked = printStatementSequence(path.get("body"), print);
        if (naked.isEmpty())
            return fromString("{}");

        return concat([
            "{\n",
            naked.indent(options.tabWidth),
            "\n}"
        ]);

    case "ReturnStatement":
        var parts = ["return"];

        if (n.argument)
            parts.push(" ", print(path.get("argument")));

        parts.push(";");

        return concat(parts);

    case "CallExpression":
        return concat([
            print(path.get("callee")),
            "(",
            fromString(", ").join(path.get("arguments").map(print)),
            ")"
        ]);

    case "ObjectExpression":
    case "ObjectPattern":
        var allowBreak = false,
            len = n.properties.length,
            parts = [len > 0 ? "{\n" : "{"];

        path.get("properties").map(function(childPath) {
            var prop = childPath.value;
            var i = childPath.name;

            var lines = print(childPath).indent(options.tabWidth);

            var multiLine = lines.length > 1;
            if (multiLine && allowBreak) {
                // Similar to the logic for BlockStatement.
                parts.push("\n");
            }

            parts.push(lines);

            if (i < len - 1) {
                // Add an extra line break if the previous object property
                // had a multi-line value.
                parts.push(multiLine ? ",\n\n" : ",\n");
                allowBreak = !multiLine;
            }
        });

        parts.push(len > 0 ? "\n}" : "}");

        return concat(parts);

    case "PropertyPattern":
        return concat([
            print(path.get("key")),
            ": ",
            print(path.get("pattern"))
        ]);

    case "Property": // Non-standard AST node type.
        if (n.method || n.kind === "get" || n.kind === "set") {
            return printMethod(
                n.kind,
                path.get("key"),
                path.get("value"),
                options,
                print
            );
        }

        return concat([
            print(path.get("key")),
            ": ",
            print(path.get("value"))
        ]);

    case "ArrayExpression":
    case "ArrayPattern":
        var elems = n.elements,
            len = elems.length,
            parts = ["["];

        path.get("elements").each(function(elemPath) {
            var elem = elemPath.value;
            if (!elem) {
                // If the array expression ends with a hole, that hole
                // will be ignored by the interpreter, but if it ends with
                // two (or more) holes, we need to write out two (or more)
                // commas so that the resulting code is interpreted with
                // both (all) of the holes.
                parts.push(",");
            } else {
                var i = elemPath.name;
                if (i > 0)
                    parts.push(" ");
                parts.push(print(elemPath));
                if (i < len - 1)
                    parts.push(",");
            }
        });

        parts.push("]");

        return concat(parts);

    case "SequenceExpression":
        return fromString(", ").join(path.get("expressions").map(print));

    case "ThisExpression":
        return fromString("this");

    case "Literal":
        if (typeof n.value !== "string")
            return fromString(n.value, options);

        // intentionally fall through...

    case "ModuleSpecifier":
        // A ModuleSpecifier is a string-valued Literal.
        return fromString(nodeStr(n), options);

    case "UnaryExpression":
        var parts = [n.operator];
        if (/[a-z]$/.test(n.operator))
            parts.push(" ");
        parts.push(print(path.get("argument")));
        return concat(parts);

    case "UpdateExpression":
        var parts = [
            print(path.get("argument")),
            n.operator
        ];

        if (n.prefix)
            parts.reverse();

        return concat(parts);

    case "ConditionalExpression":
        return concat([
            "(", print(path.get("test")),
            " ? ", print(path.get("consequent")),
            " : ", print(path.get("alternate")), ")"
        ]);

    case "NewExpression":
        var parts = ["new ", print(path.get("callee"))];
        var args = n.arguments;

        if (args) {
            parts.push(
                "(",
                fromString(", ").join(path.get("arguments").map(print)),
                ")"
            );
        }

        return concat(parts);

    case "VariableDeclaration":
        var parts = [n.kind, " "];
        var maxLen = 0;
        var printed = path.get("declarations").map(function(childPath) {
            var lines = print(childPath);
            maxLen = Math.max(lines.length, maxLen);
            return lines;
        });

        if (maxLen === 1) {
            parts.push(fromString(", ").join(printed));
        } else if (printed.length > 1 ) {
            parts.push(
                fromString(",\n").join(printed)
                    .indentTail("var ".length)
            );
        } else {
            parts.push(printed[0]);
        }

        // We generally want to terminate all variable declarations with a
        // semicolon, except when they are children of for loops.
        var parentNode = path.parent && path.parent.node;
        if (!namedTypes.ForStatement.check(parentNode) &&
            !namedTypes.ForInStatement.check(parentNode) &&
            !(namedTypes.ForOfStatement &&
              namedTypes.ForOfStatement.check(parentNode))) {
            parts.push(";");
        }

        return concat(parts);

    case "VariableDeclarator":
        return n.init ? fromString(" = ").join([
            print(path.get("id")),
            print(path.get("init"))
        ]) : print(path.get("id"));

    case "WithStatement":
        return concat([
            "with (",
            print(path.get("object")),
            ") ",
            print(path.get("body"))
        ]);

    case "IfStatement":
        var con = adjustClause(print(path.get("consequent")), options),
            parts = ["if (", print(path.get("test")), ")", con];

        if (n.alternate)
            parts.push(
                endsWithBrace(con) ? " else" : "\nelse",
                adjustClause(print(path.get("alternate")), options));

        return concat(parts);

    case "ForStatement":
        // TODO Get the for (;;) case right.
        var init = print(path.get("init")),
            sep = init.length > 1 ? ";\n" : "; ",
            forParen = "for (",
            indented = fromString(sep).join([
                init,
                print(path.get("test")),
                print(path.get("update"))
            ]).indentTail(forParen.length),
            head = concat([forParen, indented, ")"]),
            clause = adjustClause(print(path.get("body")), options),
            parts = [head];

        if (head.length > 1) {
            parts.push("\n");
            clause = clause.trimLeft();
        }

        parts.push(clause);

        return concat(parts);

    case "WhileStatement":
        return concat([
            "while (",
            print(path.get("test")),
            ")",
            adjustClause(print(path.get("body")), options)
        ]);

    case "ForInStatement":
        // Note: esprima can't actually parse "for each (".
        return concat([
            n.each ? "for each (" : "for (",
            print(path.get("left")),
            " in ",
            print(path.get("right")),
            ")",
            adjustClause(print(path.get("body")), options)
        ]);

    case "ForOfStatement":
        return concat([
            "for (",
            print(path.get("left")),
            " of ",
            print(path.get("right")),
            ")",
            adjustClause(print(path.get("body")), options)
        ]);

    case "DoWhileStatement":
        var doBody = concat([
            "do",
            adjustClause(print(path.get("body")), options)
        ]), parts = [doBody];

        if (endsWithBrace(doBody))
            parts.push(" while");
        else
            parts.push("\nwhile");

        parts.push(" (", print(path.get("test")), ");");

        return concat(parts);

    case "BreakStatement":
        var parts = ["break"];
        if (n.label)
            parts.push(" ", print(path.get("label")));
        parts.push(";");
        return concat(parts);

    case "ContinueStatement":
        var parts = ["continue"];
        if (n.label)
            parts.push(" ", print(path.get("label")));
        parts.push(";");
        return concat(parts);

    case "LabeledStatement":
        return concat([
            print(path.get("label")),
            ":\n",
            print(path.get("body"))
        ]);

    case "TryStatement":
        var parts = [
            "try ",
            print(path.get("block"))
        ];

        n.handlers.forEach(function(handler) {
            parts.push(" ", print(handler));
        });

        if (n.finalizer)
            parts.push(" finally ", print(path.get("finalizer")));

        return concat(parts);

    case "CatchClause":
        var parts = ["catch (", print(path.get("param"))];

        if (n.guard)
            // Note: esprima does not recognize conditional catch clauses.
            parts.push(" if ", print(path.get("guard")));

        parts.push(") ", print(path.get("body")));

        return concat(parts);

    case "ThrowStatement":
        return concat([
            "throw ",
            print(path.get("argument")),
            ";"
        ]);

    case "SwitchStatement":
        return concat([
            "switch (",
            print(path.get("discriminant")),
            ") {\n",
            fromString("\n").join(path.get("cases").map(print)),
            "\n}"
        ]);

        // Note: ignoring n.lexical because it has no printing consequences.

    case "SwitchCase":
        var parts = [];

        if (n.test)
            parts.push("case ", print(path.get("test")), ":");
        else
            parts.push("default:");

        if (n.consequent.length > 0) {
            parts.push("\n", printStatementSequence(
                path.get("consequent"),
                print
            ).indent(options.tabWidth));
        }

        return concat(parts);

    case "DebuggerStatement":
        return fromString("debugger;");

    // XJS extensions below.

    case "XJSAttribute":
        var parts = [print(path.get("name"))];
        if (n.value)
            parts.push("=", print(path.get("value")));
        return concat(parts);

    case "XJSIdentifier":
        var str = n.name;
        if (typeof n.namespace === "string")
            str = n.namespace + ":" + str;
        return fromString(str, options);

    case "XJSExpressionContainer":
        return concat(["{", print(path.get("expression")), "}"]);

    case "XJSElement":
        var parts = [print(path.get("openingElement"))];

        if (!n.selfClosing) {
            parts.push(
                concat(path.get("children").map(function(childPath) {
                    var child = childPath.value;
                    if (namedTypes.Literal.check(child))
                        child.type = "XJSText";
                    return print(childPath);
                })),
                print(path.get("closingElement"))
            );
        }

        return concat(parts);

    case "XJSOpeningElement":
        var parts = ["<", print(path.get("name"))];

        n.attributes.forEach(function(attr) {
            parts.push(" ", print(attr));
        });

        parts.push(n.selfClosing ? " />" : ">");

        return concat(parts);

    case "XJSClosingElement":
        return concat(["</", print(path.get("name")), ">"]);

    case "XJSText":
        return fromString(n.value, options);

    case "XJSEmptyExpression":
        return fromString("");

    case "TypeAnnotatedIdentifier":
        var parts = [
            print(path.get("annotation")),
            " ",
            print(path.get("identifier"))
        ];

        return concat(parts);

    case "ClassBody":
        return concat([
            "{\n",
            printStatementSequence(path.get("body"), print, true)
                .indent(options.tabWidth),
            "\n}"
        ]);

    case "ClassPropertyDefinition":
        var parts = ["static ", print(path.get("definition"))];
        if (!namedTypes.MethodDefinition.check(n.definition))
            parts.push(";");
        return concat(parts);

    case "ClassDeclaration":
    case "ClassExpression":
        var parts = ["class"];

        if (n.id)
            parts.push(" ", print(path.get("id")));

        if (n.superClass)
            parts.push(" extends ", print(path.get("superClass")));

        parts.push(" ", print(path.get("body")));

        return concat(parts);

    // Unhandled types below. If encountered, nodes of these types should
    // be either left alone or desugared into AST types that are fully
    // supported by the pretty-printer.

    case "ClassHeritage": // TODO
    case "ComprehensionBlock": // TODO
    case "ComprehensionExpression": // TODO
    case "Glob": // TODO
    case "TaggedTemplateExpression": // TODO
    case "TemplateElement": // TODO
    case "TemplateLiteral": // TODO
    case "GeneratorExpression": // TODO
    case "LetStatement": // TODO
    case "LetExpression": // TODO
    case "GraphExpression": // TODO
    case "GraphIndexExpression": // TODO
    case "TypeAnnotation": // TODO
    default:
        debugger;
        throw new Error("unknown type: " + JSON.stringify(n.type));
    }

    return p;
}

function printStatementSequence(path, print, inClassBody) {
    var filtered = path.filter(function(stmtPath) {
        var stmt = stmtPath.value;

        // Just in case the AST has been modified to contain falsy
        // "statements," it's safer simply to skip them.
        if (!stmt)
            return false;

        // Skip printing EmptyStatement nodes to avoid leaving stray
        // semicolons lying around.
        if (stmt.type === "EmptyStatement")
            return false;

        namedTypes.Statement.assert(stmt);

        return true;
    });

    var allowBreak = false,
        len = filtered.length,
        parts = [];

    filtered.map(function(stmtPath) {
        var lines = print(stmtPath);
        var stmt = stmtPath.value;

        if (inClassBody) {
            if (namedTypes.MethodDefinition.check(stmt))
                return lines;

            if (namedTypes.ClassPropertyDefinition.check(stmt) &&
                namedTypes.MethodDefinition.check(stmt.definition))
                return lines;
        }

        // Try to add a semicolon to anything that isn't a method in a
        // class body.
        return maybeAddSemicolon(lines);

    }).forEach(function(lines, i) {
        var multiLine = lines.length > 1;
        if (multiLine && allowBreak) {
            // Insert an additional line break before multi-line
            // statements, if we did not insert an extra line break
            // after the previous statement.
            parts.push("\n");
        }

        if (!inClassBody)
            lines = maybeAddSemicolon(lines);

        parts.push(lines);

        if (i < len - 1) {
            // Add an extra line break if the previous statement
            // spanned multiple lines.
            parts.push(multiLine ? "\n\n" : "\n");

            // Avoid adding another line break if we just added an
            // extra one.
            allowBreak = !multiLine;
        }
    });

    return concat(parts);
}

function printMethod(kind, keyPath, valuePath, options, print) {
    var parts = [];
    var key = keyPath.value;
    var value = valuePath.value;

    namedTypes.FunctionExpression.assert(value);

    if (value.async) {
        parts.push("async ");
    }

    if (!kind || kind === "init") {
        if (value.generator) {
            parts.push("*");
        }
    } else {
        assert.ok(kind === "get" || kind === "set");
        parts.push(kind, " ");
    }

    parts.push(
        print(keyPath),
        "(",
        maybeWrapParams(valuePath.get("params"), options, print),
        ") ",
        print(valuePath.get("body"))
    );

    return concat(parts);
}

function maybeWrapParams(path, options, print) {
    var printed = path.map(print);
    var joined = fromString(", ").join(printed);
    if (joined.length > 1 ||
        joined.getLineLength(1) > options.wrapColumn) {
        joined = fromString(",\n").join(printed);
        return concat(["\n", joined.indent(options.tabWidth)]);
    }
    return joined;
}

function adjustClause(clause, options) {
    if (clause.length > 1)
        return concat([" ", clause]);

    return concat([
        "\n",
        maybeAddSemicolon(clause).indent(options.tabWidth)
    ]);
}

function lastNonSpaceCharacter(lines) {
    var pos = lines.lastPos();
    do {
        var ch = lines.charAt(pos);
        if (/\S/.test(ch))
            return ch;
    } while (lines.prevPos(pos));
}

function endsWithBrace(lines) {
    return lastNonSpaceCharacter(lines) === "}";
}

function nodeStrEscape(str) {
    return str.replace(/\\/g, "\\\\")
              .replace(/"/g, "\\\"")
              // The previous line messes up my syntax highlighting
              // unless this comment includes a " character.
              .replace(/\n/g, "\\n")
              .replace(/\r/g, "\\r")
              .replace(/</g, "\\u003C")
              .replace(/>/g, "\\u003E");
}

function nodeStr(n) {
    if (/[\u0000-\u001F\u0080-\uFFFF]/.test(n.value)) {
        // use the convoluted algorithm to avoid broken low/high characters
        var str = "";
        for (var i = 0; i < n.value.length; i++) {
            var c = n.value[i];
            if (c <= "\x1F" || c >= "\x80") {
                var cc = c.charCodeAt(0).toString(16);
                while (cc.length < 4) cc = "0" + cc;
                str += "\\u" + cc;
            } else {
                str += nodeStrEscape(c);
            }
        }
        return '"' + str + '"';
    }

    return '"' + nodeStrEscape(n.value) + '"';
}

function maybeAddSemicolon(lines) {
    var eoc = lastNonSpaceCharacter(lines);
    if (!eoc || "\n};".indexOf(eoc) < 0)
        return concat([lines, ";"]);
    return lines;
}
