# EXPRESSER DATABASE
# -----------------------------------------------------------------------------
# Handles MongoDB database transactions using the `mongoskin` module. It supports
# a very simple failover mechanism where you can specify a "backup" connection
# string to which the module will connect in case the main database is down.
# If you prefer tp access Mongo directly, you can use the `db` property, for example:
# expresser.database.db.collection("mycollection").findAndModify(args...).
# <!--
# @see Settings.database
# -->
class Database

    lodash = require "lodash"
    logger = require "./logger.coffee"
    settings = require "./settings.coffee"
    mongo = require "mongoskin"

    # @property [Object] Database object (using mongoskin), will be set during `init`.
    db: null

    # INIT
    # -------------------------------------------------------------------------

    # Init the database module and test the connection straight away.
    # @param [Object] options Database init options.
    init: (options) =>
        logger.debug "Database.init", options
        lodash.assign settings.database, options if options?

        if settings.database.connString? and settings.database.connString isnt ""
            @setDb settings.database.connString, settings.database.options
        else
            logger.debug "Database.init", "No connection string set.", "Database module won't work."

    # CRUD IMPLEMENTATION
    # -------------------------------------------------------------------------

    # Get data from the database. A `collection` and `callback` must be specified. The `filter` is optional.
    # Please note that if `filter` has an _id or id field, or if it's a plain string or number, it will be used
    # to return documents by ID. Otherwise it's used as keys-values object for filtering.
    # @param [String] collection The collection name.
    # @param [String, Object] filter Optional, if a string or number, assume it's the document ID. Otherwise assume keys-values filter.
    # @param [Object] options Options to be passed to the query.
    # @option options [Integer] limit Limits the resultset to X documents.
    # @param [Method] callback Callback (err, result) when operation has finished.
    get: (collection, filter, options, callback) =>
        if not callback?
            if lodash.isFunction options
                callback = options
                options = null
            else if lodash.isFunction filter
                callback = filter
                filter = null

        # Callback is mandatory!
        if not callback?
            throw new Error "Database.get: a callback (last argument) must be specified."

        # No DB set? Throw exception.
        if not @db?
            return callback "Database.insert: the db was not initialized, please check database settings and call its 'init' method."

        # Create the DB callback helper.
        dbCallback = (err, result) =>
            if callback?
                result = @normalizeId result if settings.database.normalizeId
                callback err, result

        # Set collection object.
        dbCollection = @db.collection collection

        # Parse ID depending on `filter`.
        if filter?
            if filter._id?
                id = filter._id
            else if filter.id? and settings.database.normalizeId
                id = filter.id
            else
                t = typeof filter
                id = filter if t is "string" or t is "integer"

        # Get `limit` option.
        if options?.limit?
            limit = options.limit
        else
            limit = 0

        # Find documents depending on `filter` and `options`.
        # If id is set, use the shorter findById.
        if id?
            dbCollection.findById id, dbCallback

        # Create a params object for the find method.
        else if filter?
            findParams = {$query: filter}
            findParams["$orderby"] = options.orderBy if options?.orderBy?

            if limit > 0
                dbCollection.find(findParams).limit(limit).toArray dbCallback
            else
                dbCollection.find(findParams).toArray dbCallback

        # Search everything!
        else
            if limit > 0
                dbCollection.find({}).limit(limit).toArray dbCallback
            else
                dbCollection.find({}).toArray dbCallback

        if filter?
            filterLog = filter
            filterLog.password = "***" if filterLog.password?
            filterLog.passwordHash = "***" if filterLog.passwordHash?
            logger.debug "Database.get", collection, filterLog, options
        else
            logger.debug "Database.get", collection, "No filter.", options

    # Add new documents to the database.
    # The `options` parameter is optional.
    # @param [String] collection The collection name.
    # @param [Object] obj Document or array of documents to be added.
    # @param [Method] callback Callback (err, result) when operation has finished.
    insert: (collection, obj, callback) =>
        if not obj?
            if callback?
                callback "Database.insert: no object (second argument) was specified."
            return false

        # No DB set? Throw exception.
        if not @db?
            if callback?
                callback "Database.insert: the db was not initialized, please check database settings and call its 'init' method."
            return false

        # Create the DB callback helper.
        dbCallback = (err, result) =>
            if callback?
                result = @normalizeId(result) if settings.database.normalizeId
                callback err, result

        # Set collection object.
        dbCollection = @db.collection collection

        # Execute insert!
        dbCollection.insert obj, dbCallback
        logger.debug "Database.insert", collection

    # Update existing documents on the database.
    # The `options` parameter is optional.
    # @param [String] collection The collection name.
    # @param [Object] obj Document or data to be updated.
    # @param [Object] options Optional, options to control and filter the insert behaviour.
    # @option options [Object] filter Defines the query filter. If not specified, will try using the ID of the passed object.
    # @option options [Boolean] patch Default is false, if true replace only the specific properties of documents instead of the whole data, using $set.
    # @option options [Boolean] upsert Default is false, if true it will create documents if none was found.
    # @param [Method] callback Callback (err, result) when operation has finished.
    update: (collection, obj, options, callback) =>
        if not callback? and lodash.isFunction options
            callback = options
            options = {}

        # Object or filter is mandatory.
        if not obj?
            if callback?
                callback "Database.update: no object (second argument) was specified."
            return false

        # No DB set? Throw exception.
        if not @db?
            if callback?
                callback "Database.update: the db was not initialized, please check database settings and call its 'init' method."
            return false

        # Create the DB callback helper.
        dbCallback = (err, result) =>
            if callback?
                result = @normalizeId(result) if settings.database.normalizeId
                callback err, result

        # Set collection object.
        dbCollection = @db.collection collection

        # Make sure the ID is converted to ObjectID.
        if obj._id?
            id = mongo.ObjectID.createFromHexString obj._id.toString()
        else if obj.id? and settings.database.normalizeId
            id = mongo.ObjectID.createFromHexString obj.id.toString()

        # Make sure options is valid.
        options = {} if not options?

        # If a `filter` option was set, use it as the query filter otherwise use the "_id" property.
        if options.filter?
            filter = options.filter
        else
            filter = {"_id": id}

        # If options patch is set, replace specified document properties only instead of replacing the whole document.
        if options.patch
            docData = {$set: obj}
        else
            docData = obj

        # Set default options.
        options = lodash.defaults options, {"new": true, "insert": false}

        # Execute update!
        dbCollection.update filter, docData, options, dbCallback

        if id?
            logger.debug "Database.update", collection, options, "ID: #{id}"
        else
            logger.debug "Database.update", collection, options, "New document."

    # DEPRECATED! Alias for `update`, will be removed soon.
    set: =>
        console.warn "Database.set", "Method is deprecated, use .insert or .update instead!"
        @update.apply this, arguments

    # Delete an object from the database. The `obj` argument can be either the document itself, or its integer/string ID.
    # @param [String] collection The collection name.
    # @param [String, Object] filter If a string or number, assume it's the document ID. Otherwise assume the document itself.
    # @param [Method] callback Callback (err, result) when operation has finished.
    remove: (collection, filter, callback) =>
        if not callback? and lodash.isFunction options
            callback = options
            options = {}

        # Filter is mandatory.
        if not filter?
            if callback?
                callback "Database.remove: no filter (second argument) was specified."
            return false

        # No DB set? Throw exception.
        if not @db?
            if callback?
                callback "Database.remove: the db was not initialized, please check database settings and call its 'init' method."
            return false

        # Check it the `obj` is the model itself, or only the ID string / number.
        if filter._id?
            id = filter._id
        else if filter.id and settings.database.normalizeId
            id = filter.id
        else
            t = typeof filter
            id = filter if t is "string" or t is "integer"

        # Create the DB callback helper.
        dbCallback = (err, result) =>
            if callback?
                result = @normalizeId(result) if settings.database.normalizeId
                callback err, result

        # Set collection object and remove specified object from the database.
        dbCollection = @db.collection collection

        # Remove object by ID or filter.
        if id? and id isnt ""
            dbCollection.removeById id, dbCallback
        else
            dbCollection.remove filter, dbCallback

        logger.debug "Database.remove", collection, filter

    # Alias for `remove`.
    del: => @remove.apply this, arguments

    # Count documents from the database. A `collection` must be specified.
    # If no `filter` is not passed then count all documents.
    # @param [String] collection The collection name.
    # @param [Object] filter Optional, keys-values filter of documents to be counted.
    # @param [Method] callback Callback (err, result) when operation has finished.
    count: (collection, filter, callback) =>
        if not callback? and lodash.isFunction filter
            callback = filter
            filter = {}

        # Callback is mandatory!
        if not callback?
            throw new Error "Database.count: a callback (last argument) must be specified."

        # Create the DB callback helper.
        dbCallback = (err, result) =>
            if callback?
                logger.debug "Database.count", collection, filter, "Result #{result}"
                callback err, result

        # MongoDB has a built-in count so use it.
        dbCollection = @db.collection collection
        dbCollection.count filter, dbCallback

    # HELPER METHODS
    # -------------------------------------------------------------------------

    # Helper to transform MongoDB document "_id" to "id".
    # @param [Object] result The document or result to be normalized.
    # @return [Object] Returns the normalized document.
    normalizeId: (result) =>
        return if not result?

        isArray = lodash.isArray result or lodash.isArguments result

        # Check if result is a collection / array or a single document.
        if isArray
            for obj in result
                if obj["_id"]?
                    obj["id"] = obj["_id"].toString()
                    delete obj["_id"]
        else if result["_id"]?
            result["id"] = result["_id"].toString()
            delete result["_id"]

        return result

    # Helper to set the current DB object. Can be called externally but ideally you should control
    # the connection string by updating your app settings.json file.
    # @param [Object] connString The connection string, for example user:password@hostname/dbname.
    # @param [Object] options Additional options to be passed when creating the DB connection object.
    setDb: (connString, options) =>
        @db = mongo.db connString, options

        # Safe logging, strip username and password.
        sep = connString.indexOf "@"
        connStringSafe = connString
        connStringSafe = connStringSafe.substring sep if sep > 0
        logger.debug "Database.setDb", connStringSafe, options

# Singleton implementation.
# -----------------------------------------------------------------------------
Database.getInstance = ->
    @instance = new Database() if not @instance?
    return @instance

module.exports = exports = Database.getInstance()
