# Framework for building Falafel based transforms for Browserify.

path    = require 'path'
fs      = require 'fs'

through   = require 'through'
falafel   = require 'falafel'

loadConfig = require './loadConfig'
skipFile = require './skipFile'

exports.loadTransformConfig = loadConfig.loadTransformConfig
exports.loadTransformConfigSync = loadConfig.loadTransformConfigSync
exports.skipFile = skipFile

# TODO: Does this work on Windows?
isRootDir = (filename) -> filename == path.resolve(filename, '/')

merge = (a={}, b={}) ->
    answer = {}
    answer[key] = a[key] for key of a
    answer[key] = b[key] for key of b
    return answer

clone = (a) ->
    if !a then return a
    answer = {}
    answer[key] = a[key] for key of a
    return answer


# Create a new Browserify transform which reads and returns a string.
#
# Browserify transforms work on streams.  This is all well and good, until you want to call
# a library like "falafel" which doesn't work with streams.
#
# Suppose you are writing a transform called "redify" which replaces all occurances of "blue"
# with "red":
#
#     options = {}
#     module.exports = makeStringTransform "redify", options, (contents, transformOptions, done) ->
#         done null, contents.replace(/blue/g, "red")
#
# Parameters:
# * `transformFn(contents, transformOptions, done)` - Function which is called to
#   do the transform.  `contents` are the contents of the file.  `transformOptions.file` is the
#   name of the file (as would be passed to a normal browserify transform.)
#   `transformOptions.configData` is the configuration data for the transform (see
#   `loadTransformConfig` below for details.)  `transformOptions.config` is a copy of
#   `transformOptions.configData.config` for convenience.  `done(err, transformed)` is a callback
#   which must be called, passing the a string with the transformed contents of the file.
# * `options.excludeExtensions` - A list of extensions which will not be processed.  e.g.
#   "['.coffee', '.jade']"
# * `options.includeExtensions` - A list of extensions to process.  If this options is not
#   specified, then all extensions will be processed.  If this option is specified, then
#   any file with an extension not in this list will skipped.
# * `options.jsFilesOnly` - If true (and if includeExtensions is not set) then this transform
#   will only operate on .js files, and on files which are commonly compiled to javascript
#   (.coffee, .litcoffee, .coffee.md, .jade, etc...)
#
exports.makeStringTransform = (transformName, options={}, transformFn) ->
    if !transformFn?
        transformFn = options
        options = {}

    transform = (file, config) ->
        configData = if transform.configData?
            transform.configData
        else
            loadConfig.loadTransformConfigSync transformName, file, options

        if config?
            configData = clone(configData) ? {config:{}}
            configData.config = merge configData.config, config

            if configData.config.appliesTo
                configData.appliesTo = configData.config.appliesTo
                delete configData.config.appliesTo

        if skipFile file, configData, options then return through()

        # Read the file contents into `content`
        content = ''
        write = (buf) -> content += buf

        # Called when we're done reading file contents
        end = ->
            handleError = (error) =>
                suffix = " (while #{transformName} was processing #{file})"
                if error instanceof Error and error.message
                    error.message += suffix
                else
                    error = new Error("#{error}#{suffix}")
                @emit 'error', error

            try
                transformOptions = {
                    file: file,
                    configData: configData,
                    config: configData?.config,
                    opts: configData?.config
                }
                transformFn.call @, content, transformOptions, (err, transformed) =>
                    return handleError err if err
                    @queue String(transformed)
                    @queue null
            catch err
                handleError err

        return through write, end

    # Called to manually pass configuration data to the transform.  Configuration passed in this
    # way will override configuration loaded from package.json.
    #
    # * `config` is the configuration data.
    # * `configOptions.configFile` is the file that configuration data was loaded from.  If this
    #   is specified and `configOptions.configDir` is not specified, then `configOptions.configDir`
    #   will be inferred from the configFile's path.
    # * `configOptions.configDir` is the directory the configuration was loaded from.  This is used
    #   by some transforms to resolve relative paths.
    #
    # Returns a new transform that uses the configuration:
    #
    #     myTransform = require('myTransform').configure(...)
    #
    transform.configure = (config, configOptions = {}) ->
        answer = exports.makeStringTransform transformName, options, transformFn
        answer.setConfig config, configOptions
        return answer

    # Similar to `configure()`, but modifies the transform instance it is called on.  This can
    # be used to set the default configuration for the transform.
    transform.setConfig = (config, configOptions = {}) ->
        configFile = configOptions.configFile or null
        configDir = configOptions.configDir or if configFile then path.dirname configFile else null

        if !config
            @configData = null
        else
            @configData = {
                config: config,
                configFile: configFile,
                configDir: configDir,
                cached: false
            }

            if config.appliesTo
                @configData.appliesTo = config.appliesTo
                delete config.appliesTo


        return this


    return transform


# Create a new Browserify transform based on [falafel](https://github.com/substack/node-falafel).
#
# Parameters:
# * `transformFn(node, transformOptions, done)` is called once for each falafel node.  transformFn
#   is free to update the falafel node directly; any value returned via `done(err)` is ignored.
# * `options.falafelOptions` are options to pass directly to Falafel.
# * `transformName`, `options.excludeExtensions`, `options.includeExtensions`, `options.jsFilesOnly`,
#   and `tranformOptions` are the same as for `makeStringTransform()`.
#
exports.makeFalafelTransform = (transformName, options={}, transformFn) ->
    if !transformFn?
        transformFn = options
        options = {}

    falafelOptions = options.falafelOptions ? {}

    transform = exports.makeStringTransform transformName, options, (content, transformOptions, done) ->
        transformErr = null
        pending = 1 # We'll decrement this to zero at the end to prevent premature call of `done`.
        transformed = null

        transformCb = (err) ->
            if err and !transformErr
                transformErr = err
                done err

            # Stop further processing if an error has occurred
            return if transformErr

            pending--
            if pending is 0
                done null, transformed

        transformed = falafel content, falafelOptions, (node) ->
            pending++
            try
                transformFn node, transformOptions, transformCb
            catch err
                transformCb err

        # call transformCb one more time to decrement pending to 0.
        transformCb transformErr, transformed

    # Called to manually pass configuration data to the transform.  Configuration passed in this
    # way will override configuration loaded from package.json.
    #
    # * `config` is the configuration data.
    # * `configOptions.configFile` is the file that configuration data was loaded from.  If this
    #   is specified and `configOptions.configDir` is not specified, then `configOptions.configDir`
    #   will be inferred from the configFile's path.
    # * `configOptions.configDir` is the directory the configuration was loaded from.  This is used
    #   by some transforms to resolve relative paths.
    #
    # Returns a new transform that uses the configuration:
    #
    #     myTransform = require('myTransform').configure(...)
    #
    transform.configure = (config, configOptions = {}) ->
        answer = exports.makeFalafelTransform transformName, options, transformFn
        answer.setConfig config, configOptions
        return answer

    return transform

# Create a new Browserify transform that modifies requires() calls.
#
# The resulting transform will call `transformFn(requireArgs, tranformOptions, cb)` for every
# requires in a file.  transformFn should call `cb(null, str)` with a string which will replace the
# entire `require` call.
#
# Exmaple:
#
#     makeRequireTransform "xify", (requireArgs, cb) ->
#         cb null, "require(x" + requireArgs[0] + ")"
#
# would transform calls like `require("foo")` into `require("xfoo")`.
#
# `transformName`, `options.excludeExtensions`, `options.includeExtensions`, `options.jsFilesOnly`,
# and `tranformOptions` are the same as for `makeStringTransform()`.
#
# By default, makeRequireTransform will attempt to evaluate each "require" parameters.
# makeRequireTransform can handle variabls `__filename`, `__dirname`, `path`, and `join` (where
# `join` is treated as `path.join`) as well as any basic JS expressions.  If the argument is
# too complicated to parse, then makeRequireTransform will return the source for the argument.
# You can disable parsing by passing `options.evaluateArguments` as false.
#
exports.makeRequireTransform = (transformName, options={}, transformFn) ->
    if !transformFn?
        transformFn = options
        options = {}

    evaluateArguments = options.evaluateArguments ? true

    transform = exports.makeFalafelTransform transformName, options, (node, transformOptions, done) ->
        if (node.type is 'CallExpression' and node.callee.type is 'Identifier' and
        node.callee.name is 'require')
            # Parse arguemnts to calls to `require`.
            args = evaluateFunctionArgs evaluateArguments, transformOptions, node

            transformFn args.values(), transformOptions, (err, transformed) ->
                return done err if err
                if transformed? then node.update(transformed)
                done()
        else
            done()

    # Called to manually pass configuration data to the transform.  Configuration passed in this
    # way will override configuration loaded from package.json.
    #
    # * `config` is the configuration data.
    # * `configOptions.configFile` is the file that configuration data was loaded from.  If this
    #   is specified and `configOptions.configDir` is not specified, then `configOptions.configDir`
    #   will be inferred from the configFile's path.
    # * `configOptions.configDir` is the directory the configuration was loaded from.  This is used
    #   by some transforms to resolve relative paths.
    #
    # Returns a new transform that uses the configuration:
    #
    #     myTransform = require('myTransform').configure(...)
    #
    transform.configure = (config, configOptions = {}) ->
        answer = exports.makeRequireTransform transformName, options, transformFn
        answer.setConfig config, configOptions
        return answer

    return transform

# Create a new Browserify transform that modifies arbitrary function calls.
#
# The resulting transform will call `transformFn({functionName: [functionName], args: {value: [arg], type: [type]}}, tranformOptions, cb)` for every
# given function alias in a file.  transformFn should call `cb(null, str)` with a string which will replace the
# entire function call.
#
# Exmaple:
#
#     makeFunctionTransform "xify", {functionNames: ["baz"]}, (functionParams, cb) ->
#         cb null, functionParams.name + "('x" + functionParams.args[0].value + "')"
#
# would transform calls like `baz("foo")` into `baz("xfoo")`.
#
# `transformName`, `options.excludeExtensions`, `options.includeExtensions`, `options.jsFilesOnly`,
# and `tranformOptions` are the same as for `makeStringTransform()`.
#
# `options.functionNames` is an optional parameter which can be a string or an array of strings.
# These strings are taken to identify function occurences in the code. If nothing is passed as a function name
# makeFunctionTransform falls back to `require()` calls.
#
# By default, makeFunctionTransform will attempt to evaluate each "require" parameters.
# makeFunctionTransform can handle variabls `__filename`, `__dirname`, `path`, and `join` (where
# `join` is treated as `path.join`) as well as any basic JS expressions.  If the argument is
# too complicated to parse, then makeFunctionTransform will return the source for the argument.
# You can disable parsing by passing `options.evaluateArguments` as false.
#
exports.makeFunctionTransform = (transformName, options={}, transformFn) ->
    if !transformFn?
        transformFn = options
        options = {}

    evaluateArguments = options.evaluateArguments ? true

    functionNames = []
    if options.functionNames?
        if Array.isArray(options.functionNames) || {}.toString.call(options.functionNames) is '[object Array]'
            functionNames = options.functionNames
        else if typeof options.functionNames is 'string'
            functionNames = [options.functionNames]
    if functionNames.length is 0
        functionNames.push 'require'

    transform = exports.makeFalafelTransform transformName, options, (node, transformOptions, done) ->
        if (node.type is 'CallExpression' and node.callee.type is 'Identifier' and
          node.callee.name in functionNames)
            # Parse arguments to calls to a given function name.
            args = evaluateFunctionArgs evaluateArguments, transformOptions, node

            transformFn {name: node.callee.name, args: args}, transformOptions, (err, transformed) ->
                return done err if err
                if transformed? then node.update(transformed)
                done()
        else
            done()

    # Called to manually pass configuration data to the transform.  Configuration passed in this
    # way will override configuration loaded from package.json.
    #
    # * `config` is the configuration data.
    # * `configOptions.configFile` is the file that configuration data was loaded from.  If this
    #   is specified and `configOptions.configDir` is not specified, then `configOptions.configDir`
    #   will be inferred from the configFile's path.
    # * `configOptions.configDir` is the directory the configuration was loaded from.  This is used
    #   by some transforms to resolve relative paths.
    #
    # Returns a new transform that uses the configuration:
    #
    #     myTransform = require('myTransform').configure(...)
    #
    transform.configure = (config, configOptions = {}) ->
        answer = exports.makeFunctionTransform transformName, options, transformFn
        answer.setConfig config, configOptions
        return answer

    return transform

# Runs a Browserify-style transform on the given file.
#
# * `transform` is the transform to run (i.e. a `fn(file)` which returns a through stream.)
# * `file` is the name of the file to run the transform on.
# * `options.content` is the content of the file.  If this option is not provided, the content
#   will be read from disk.
# * `options.config` is configuration to pass along to the transform.
# * `done(err, result)` will be called with the transformed input.
#
exports.runTransform = (transform, file, options={}, done) ->
    if !done?
        done = options
        options = {}

    doTransform = (content) ->
        data = ""
        err = null

        throughStream = if options.config?
            transform(file, options.config)
        else
            transform(file)

        throughStream.on "data", (d) ->
            data += d
        throughStream.on "end", ->
            if !err then done null, data
        throughStream.on "error", (e) ->
            err = e
            done err

        throughStream.write content
        throughStream.end()

    if options.content
        process.nextTick -> doTransform options.content
    else
        fs.readFile file, "utf-8", (err, content) ->
            return done err if err
            doTransform content

evaluateFunctionArgs = (evaluateArguments, transformOptions, node) ->
    if evaluateArguments
        # Based on https://github.com/ForbesLindesay/rfileify.
        dirname = path.dirname(transformOptions.file)
        varNames = ['__filename', '__dirname', 'path', 'join']
        vars = [transformOptions.file, dirname, path, path.join]

        args = node.arguments.map (arg) ->
            t = "return #{arg.source()}"
            try
                return {value: Function(varNames, t).apply(null, vars), type: arg.type}
            catch err
                # Can't evaluate the arguments.  Return the raw source.
                return {value: arg.source(), type: arg.type}
    else
        args = ({value: arg.source(), type: arg.type} for arg in node.arguments)

    args.values = ->
        values = []
        for arg in this
            if arg.value?
                values.push arg.value
        values
    
    args
