/*
 * Author: Alex Kocharin <alex@kocharin.ru>
 * GIT: https://github.com/rlidwka/jju
 * License: WTFPL, grab your copy here: http://www.wtfpl.net/txt/copying/
 */

var Uni = require('./unicode')

// Fix Function#name on browsers that do not support it (IE)
// http://stackoverflow.com/questions/6903762/function-name-not-supported-in-ie
if (!(function f(){}).name) {
	Object.defineProperty((function(){}).constructor.prototype, 'name', {
		get: function() {
			var name = this.toString().match(/^\s*function\s*(\S*)\s*\(/)[1]
			// For better performance only parse once, and then cache the
			// result through a new accessor for repeated access.
			Object.defineProperty(this, 'name', { value: name })
			return name
		}
	})
}

var special_chars = {
	0: '\\0', // this is not an octal literal
	8: '\\b',
	9: '\\t',
	10: '\\n',
	11: '\\v',
	12: '\\f',
	13: '\\r',
	92: '\\\\',
}

// for oddballs
var hasOwnProperty = Object.prototype.hasOwnProperty

// some people escape those, so I'd copy this to be safe
var escapable = /[\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/

function _stringify(object, options, recursiveLvl, currentKey) {
	var opt_json = options.mode === 'json'
	/*
	 * Opinionated decision warning:
	 *
	 * Objects are serialized in the following form:
	 * { type: 'Class', data: DATA }
	 *
	 * Class is supposed to be a function, and new Class(DATA) is
	 * supposed to be equivalent to the original value
	 */
	/*function custom_type() {
		return stringify({
			type: object.constructor.name,
			data: object.toString()
		})
	}*/

	// if add, it's an internal indentation, so we add 1 level and a eol
	// if !add, it's an ending indentation, so we just indent
	function indent(str, add) {
		var prefix = options._prefix ? options._prefix : ''
		if (!options.indent) return prefix + str
		var result = ''
		var count = recursiveLvl + (add || 0)
		for (var i=0; i<count; i++) result += options.indent
		return prefix + result + str + (add ? '\n' : '')
	}

	function _stringify_key(key) {
		if (options.quote_keys) return _stringify_str(key)
		if (String(Number(key)) == key && key[0] != '-') return key
		if (key == '') return _stringify_str(key)

		var result = ''
		for (var i=0; i<key.length; i++) {
			if (i > 0) {
				if (!Uni.isIdentifierPart(key[i]))
					return _stringify_str(key)

			} else {
				if (!Uni.isIdentifierStart(key[i]))
					return _stringify_str(key)
			}

			var chr = key.charCodeAt(i)

			if (options.ascii) {
				if (chr < 0x80) {
					result += key[i]

				} else {
					result += '\\u' + ('0000' + chr.toString(16)).slice(-4)
				}

			} else {
				if (escapable.exec(key[i])) {
					result += '\\u' + ('0000' + chr.toString(16)).slice(-4)

				} else {
					result += key[i]
				}
			}
		}

		return result
	}

	function _stringify_str(key) {
		var quote = options.quote
		var quoteChr = quote.charCodeAt(0)

		var result = ''
		for (var i=0; i<key.length; i++) {
			var chr = key.charCodeAt(i)

			if (chr < 0x10) {
				if (chr === 0 && !opt_json) {
					result += '\\0'
				} else if (chr >= 8 && chr <= 13 && (!opt_json || chr !== 11)) {
					result += special_chars[chr]
				} else if (!opt_json) {
					result += '\\x0' + chr.toString(16)
				} else {
					result += '\\u000' + chr.toString(16)
				}

			} else if (chr < 0x20) {
				if (!opt_json) {
					result += '\\x' + chr.toString(16)
				} else {
					result += '\\u00' + chr.toString(16)
				}

			} else if (chr >= 0x20 && chr < 0x80) {
				// ascii range
				if (chr === 47 && i && key[i-1] === '<') {
					// escaping slashes in </script>
					result += '\\' + key[i]

				} else if (chr === 92) {
					result += '\\\\'

				} else if (chr === quoteChr) {
					result += '\\' + quote

				} else {
					result += key[i]
				}

			} else if (options.ascii || Uni.isLineTerminator(key[i]) || escapable.exec(key[i])) {
				if (chr < 0x100) {
					if (!opt_json) {
						result += '\\x' + chr.toString(16)
					} else {
						result += '\\u00' + chr.toString(16)
					}

				} else if (chr < 0x1000) {
					result += '\\u0' + chr.toString(16)

				} else if (chr < 0x10000) {
					result += '\\u' + chr.toString(16)

				} else {
					throw new Error('weird codepoint')
				}
			} else {
				result += key[i]
			}
		}
		return quote + result + quote
	}

	function _stringify_object() {
		if (object === null) return 'null'
		var result = []
		  , len = 0
		  , braces

		if (Array.isArray(object)) {
			braces = '[]'
			for (var i=0; i<object.length; i++) {
				var s = _stringify(object[i], options, recursiveLvl+1, String(i))
				if (s === undefined) s = 'null'
				len += s.length + 2
				result.push(s + ',')
			}

		} else {
			braces = '{}'
			var fn = function(key) {
				var t = _stringify(object[key], options, recursiveLvl+1, key)
				if (t !== undefined) {
					t = _stringify_key(key) + ':' + (options.indent ? ' ' : '') + t + ','
					len += t.length + 1
					result.push(t)
				}
			}

			if (Array.isArray(options.replacer)) {
				for (var i=0; i<options.replacer.length; i++) 
					if (hasOwnProperty.call(object, options.replacer[i]))
						fn(options.replacer[i])
			} else {
				var keys = Object.keys(object)
				if (options.sort_keys)
					keys = keys.sort(typeof(options.sort_keys) === 'function'
					                 ? options.sort_keys : undefined)
				keys.forEach(fn)
			}
		}

		// objects shorter than 30 characters are always inlined
		// objects longer than 60 characters are always splitted to multiple lines
		// anything in the middle depends on indentation level
		len -= 2
		if (options.indent && (len > options._splitMax - recursiveLvl * options.indent.length || len > options._splitMin) ) {
			// remove trailing comma in multiline if asked to
			if (options.no_trailing_comma && result.length) {
				result[result.length-1] = result[result.length-1].substring(0, result[result.length-1].length-1)
			}

			var innerStuff = result.map(function(x) {return indent(x, 1)}).join('')
			return braces[0]
				  + (options.indent ? '\n' : '')
				  + innerStuff
				  + indent(braces[1])
		} else {
			// always remove trailing comma in one-lined arrays
			if (result.length) {
				result[result.length-1] = result[result.length-1].substring(0, result[result.length-1].length-1)
			}

			var innerStuff = result.join(options.indent ? ' ' : '')
			return braces[0]
				  + innerStuff
				  + braces[1]
		}
	}

	function _stringify_nonobject(object) {
		if (typeof(options.replacer) === 'function') {
			object = options.replacer.call(null, currentKey, object)
		}

		switch(typeof(object)) {
			case 'string':
				return _stringify_str(object)

			case 'number':
				if (object === 0 && 1/object < 0) {
					// Opinionated decision warning:
					//
					// I want cross-platform negative zero in all js engines
					// I know they're equal, but why lose that tiny bit of
					// information needlessly?
					return '-0'
				}
				if (options.mode === 'json' && !Number.isFinite(object)) {
					// json don't support infinity (= sucks)
					return 'null'
				}
				return object.toString()

			case 'boolean':
				return object.toString()

			case 'undefined':
				return undefined

			case 'function':
//				return custom_type()

			default:
				// fallback for something weird
				return JSON.stringify(object)
		}
	}

	if (options._stringify_key) {
		return _stringify_key(object)
	}

	if (typeof(object) === 'object') {
		if (object === null) return 'null'

		var str
		if (typeof(str = object.toJSON5) === 'function' && options.mode !== 'json') {
			object = str.call(object, currentKey)

		} else if (typeof(str = object.toJSON) === 'function') {
			object = str.call(object, currentKey)
		}

		if (object === null) return 'null'
		if (typeof(object) !== 'object') return _stringify_nonobject(object)

		if (object.constructor === Number || object.constructor === Boolean || object.constructor === String) {
			object = object.valueOf()
			return _stringify_nonobject(object)

		} else if (object.constructor === Date) {
			// only until we can't do better
			return _stringify_nonobject(object.toISOString())

		} else {
			if (typeof(options.replacer) === 'function') {
				object = options.replacer.call(null, currentKey, object)
				if (typeof(object) !== 'object') return _stringify_nonobject(object)
			}

			return _stringify_object(object)
		}
	} else {
		return _stringify_nonobject(object)
	}
}

/*
 * stringify(value, options)
 * or
 * stringify(value, replacer, space)
 *
 * where:
 * value - anything
 * options - object
 * replacer - function or array
 * space - boolean or number or string
 */
module.exports.stringify = function stringifyJSON(object, options, _space) {
	// support legacy syntax
	if (typeof(options) === 'function' || Array.isArray(options)) {
		options = {
			replacer: options
		}
	} else if (typeof(options) === 'object' && options !== null) {
		// nothing to do
	} else {
		options = {}
	}
	if (_space != null) options.indent = _space

	if (options.indent == null) options.indent = '\t'
	if (options.quote == null) options.quote = "'"
	if (options.ascii == null) options.ascii = false
	if (options.mode == null) options.mode = 'simple'

	if (options.mode === 'json') {
		// json only supports double quotes (= sucks)
		options.quote = '"'

		// json don't support trailing commas (= sucks)
		options.no_trailing_comma = true

		// json don't support unquoted property names (= sucks)
		options.quote_keys = true
	}

	// why would anyone use such objects?
	if (typeof(options.indent) === 'object') {
		if (options.indent.constructor === Number
		||  options.indent.constructor === Boolean
		||  options.indent.constructor === String)
			options.indent = options.indent.valueOf()
	}

	// gap is capped at 10 characters
	if (typeof(options.indent) === 'number') {
		if (options.indent >= 0) {
			options.indent = Array(Math.min(~~options.indent, 10) + 1).join(' ')
		} else {
			options.indent = false
		}
	} else if (typeof(options.indent) === 'string') {
		options.indent = options.indent.substr(0, 10)
	}

	if (options._splitMin == null) options._splitMin = 50
	if (options._splitMax == null) options._splitMax = 70

	return _stringify(object, options, 0, '')
}

