'use strict'

const assert = require('assert')
const cliui = require('cliui')
const path = require('path')

// only way to trigger is run as non-module, which can't be instrumented
/* istanbul ignore next */
const $0 = require.main ? path.basename(require.main.filename) : '$0'

const _flag = Symbol('flag')
const flag = options => ({
  [_env]: false,
  [_list]: false,
  ...(options || {}),
  [_num]: false,
  [_opt]: false,
  [_flag]: true
})
const isFlag = arg => arg[_flag]

const _opt = Symbol('opt')
const opt = options => ({
  [_num]: false,
  [_list]: false,
  [_env]: false,
  ...(options || {}),
  [_flag]: false,
  [_opt]: true
})
const isOpt = arg => arg[_opt]

const _num = Symbol('num')
const num = options => (opt({
  ...(options || {}),
  [_num]: true,
}))
const isNum = arg => arg[_num]

const isArg = arg => isOpt(arg) || isFlag(arg)

const _env = Symbol('env')
const env = options => ({
  [_flag]: false,
  [_list]: false,
  [_opt]: true,
  [_num]: false,
  ...(options || {}),
  [_env]: true,
})
const isEnv = arg => arg[_env]

const _list = Symbol('list')
const list = options => ({
  [_flag]: false,
  [_opt]: true,
  [_num]: false,
  [_env]: false,
  ...(options || {}),
  [_list]: true
})
const isList = arg => arg[_list]

const count = options => list(flag(options))
const isCount = arg => isList(arg) && isFlag(arg)

const trim = string => string
  // remove single line breaks
  .replace(/([^\n])\n[ \t]*([^\n])/g, '$1 $2')
  // normalize mid-line whitespace
  .replace(/([^\n])[ \t]+([^\n])/g, '$1 $2')
  // two line breaks are enough
  .replace(/\n{3,}/g, '\n\n')
  .trim()

const usageMemo = Symbol('usageMemo')
const usage = j => {
  if (j[usageMemo])
    return j[usageMemo]

  const width = Math.min(
    process && process.stdout && process.stdout.columns || 80,
    80
  )

  const ui = cliui({ width })

  if (!/^Usage:/.test(j.help[0].text)) {
    ui.div('Usage:')
    ui.div({
      text: `${$0} <options>`,
      padding: [0, 0, 1, 2]
    })
  }

  let maxMax = 26
  let maxWidth = 8
  let prev = null
  j.help.forEach(row => {
    // find the max width of left-hand sides, and decide whether or not
    // to skip a line based on the length of the next option
    if (row.left) {
      if (row.text.length > width - maxMax) {
        if (prev)
          prev.skipLine = true
        row.skipLine = true
      }
      prev = row
      const len = row.left.length + 4
      if (len > maxWidth && len < maxMax)
        maxWidth = len
    } else {
      if (prev)
        prev.skipLine = true
      prev = null
    }
  })

  j.help.forEach(row => {
    if (row.left) {
      // If the row is too long, don't wrap it
      // Bump the right-hand side down a line to make room
      if (row.left.length >= maxWidth - 2) {
        ui.div({
          text: row.left,
          padding: [ 0, 0, 0, 2 ]
        })
        ui.div({
          text: row.text,
          padding: [ 0, 0, 0, maxWidth ]
        })
      } else {
        ui.div({
          text: row.left,
          padding: [0, 1, 0, 2],
          width: maxWidth,
        }, { text: row.text })
      }
      if (row.skipLine)
        ui.div()
    } else
      ui.div(row)
  })

  return j[usageMemo] = ui.toString()
}

// parse each section
// Resulting internal object looks like:
// {
//   help: [
//     { text, padding, left }, ...
//   ],
//   // single-char shorthands
//   shortOpts: { char: name, ... }
//   shortFlags: { char: name, ... }
//   options: { all opts and flags }
//   main: mainFn or null,
//   argv: argument list being parsed,
//   result: parsed object passed to main function and returned
//   implies: all the implied variable values (set last)
// }
const jack = (...sections) => execute(parse_(buildParser(newObj(), sections)))

const kvToArg = j => (k, v) =>
  j.options[k] && isFlag(j.options[k]) ? (v ? `--${k}` : `--no-${k}`)
  : v === null || v === undefined ? `--${k}=`
  : `--${k}=${v}`

const objToArgv = j => obj => Object.keys(obj).reduce((set, k) => {
  const toArg = kvToArg(j)
  const val = obj[k]
  if (Array.isArray(val))
    set.push(...val.map(v => toArg(k, v)))
  else
    set.push(toArg(k, val))
  return set
}, [])

const update = j => args => {
  const argv = []
  const toArgv = objToArgv(j)
  if (typeof args === 'string')
    argv.push(args)
  else if (Array.isArray(args))
    argv.push(...args)
  else if (args)
    argv.push(...toArgv(args))
  const result = reparse(j)(argv)
  Object.keys(result)
    .filter(k => k !== '_' && !j.explicit.has(k))
    .forEach(k => j.result[k] = result[k])
}

const reparse = j => args => {
  const argv = Array.isArray(args) ? args : [args]
  return parse_({
    ...j,
    explicit: new Set(),
    result: { _: [] },
    help: [],
    main: null,
    argv,
  }).result
}

const newObj = () => ({
  help: [],
  shortOpts: {},
  shortFlags: {},
  options: {},
  result: { _: [] },
  explicit: new Set(),
  main: null,
  argv: null,
  env: null,
  implies: {},
  [usageMemo]: false,
})

const execute = j => {
  if (j.result.help)
    console.log(usage(j))
  else if (j.main)
    j.main(j.result)
  return j.result
}

const buildParser = (j, sections) => {
  let inHeader = true

  sections.forEach(section => {
    if (Array.isArray(section))
      section = { argv: section }
    if (section.argv && !isArg(section.argv)) {
      !j.argv || assert(false, 'argv specified multiple times')
      j.argv = section.argv
    }

    if (section.env && !isArg(section.env)) {
      !j.env || assert(false, 'env specified multiple times\n' +
             '(did you set it after defining some environment args?)')
      j.env = section.env
    }

    if (section.usage && !isArg(section.usage)) {
      const val = section.usage
      typeof val === 'string' || Array.isArray(val) || assert(false,
             'usage must be a string or array')
      j.help.push({ text: 'Usage:' })
      j.help.push.apply(j.help, [].concat(val).map(text => ({
        text, padding: [0, 0, 0, 2]
      })))
      j.help.push({ text: '' })
    }

    if (section.description && !isArg(section.description)) {
      typeof section.description === 'string' || assert(false,
             'description must be string')
      inHeader = false
      j.help.push({
        text: trim(`${section.description}:`),
        padding: [0, 0, 1, 0]
      })
    }

    if (section.help && !isArg(section.help)) {
      typeof section.help === 'string' || assert(false, 'help must be a string')
      j.help.push({
        text: trim(section.help) + '\n',
        padding: inHeader ? null : [ 0, 0, 0, 2 ]
      })
    }

    if (section.main && !isArg(section.main))
      addMain(j, section.main)

    !section._ || assert(false, '_ is reserved for positional arguments')

    const names = Object.keys(section)
    for (let n = 0; n < names.length; n++) {
      inHeader = false
      const name = names[n]
      const val = section[name]

      if (isEnv(val))
        addEnv(j, name, val)
      else if (isArg(val))
        addArg(j, name, val)
      else {
        switch (name) {
          case 'argv':
          case 'description':
          case 'usage':
          case 'help':
          case 'main':
          case 'env':
            continue
          default:
            assert(false, `${name} not flag, opt, or env`)
        }
      }
    }
  })

  // if not already mentioned, add the note about -h and `--` ending parsing
  if (!j.options.help)
    addArg(j, 'help', flag({ description: 'Show this helpful output' }))

  if (!j.options['--']) {
    addHelpText(j, '', flag({
      description: `Stop parsing flags and options, treat any additional
                    command line arguments as positional arguments.`
    }))
  }

  return j
}

const envToNum = (name, spec) => e => {
  if (e === '')
    return undefined

  return toNum(e, `environment variable ${name}`, spec)
}

const envToBool = name => e => {
  typeof e === 'boolean' ||
    e === '' ||
    e === '1' ||
    e === '0' ||
    typeof e === 'number' ||
    assert(false,
      `Environment variable ${name} must be set to 0 or 1 only`)
  return !!+e
}

const countBools = l => l.reduce((v, a) => v ? a + 1 : a - 1, 0)

const envVal = (j, name, val) => {
  if (!j.env)
    j.env = process.env

  const def = val.default
  const has = Object.prototype.hasOwnProperty.call(j.env, name)
  const e = has && j.env[name] !== '' ? j.env[name]
    : def !== undefined ? def
    : ''

  if (isList(val)) {
    val.delimiter || assert(false, `env list ${name} lacks delimiter`)
    if (!has)
      return []
    else {
      const split = e.split(val.delimiter)
      return isFlag(val) ? countBools(split.map(envToBool(name)))
        : isNum(val) ? split.map(envToNum(name, val)).filter(e => e !== undefined)
        : split
    }
  } else if (isFlag(val))
    return envToBool(name)(e)
  else if (isNum(val))
    return envToNum(name, val)(e)
  else
    return e
}

const addEnv = (j, name, val) => {
  assertNotDefined(j, name)
  const e = envVal(j, name, val)
  if (Object.prototype.hasOwnProperty.call(j.env, name))
    set(j, name, val, e)
  else
    j.result[name] = e
  addHelpText(j, name, val)
}

const assertNotDefined = (j, name) =>
  !j.options[name] && !j.shortOpts[name] && !j.shortFlags[name] ||
    assert(false, `${name} defined multiple times`)

const addArg = (j, name, val) => {
  assertNotDefined(j, name)
  if (val.envDefault)
    val.default = envVal(j, val.envDefault, val)

  if (isFlag(val))
    addFlag(j, name, val)
  else
    addOpt(j, name, val)
}

const addOpt = (j, name, val) => {
  addShort(j, name, val)
  j.options[name] = val
  addHelpText(j, name, val)
  if (!val.alias)
    j.result[name] = isList(val) ? [] : val.default
}

const addFlag = (j, name, val) => {
  if (name === '--') {
    addHelpText(j, '', flag(val))
    j.options['--'] = true
    return
  }

  if (name === 'help' && !val.short)
    val.short = 'h'

  const negate = name.substr(0, 3) === 'no-'
  // aliases can't be negated
  if (!negate && !val.alias)
    !j.options[`no-${name}`] || assert(false,
      `flag '${name}' specified, but 'no-${name}' already defined`)

  addShort(j, name, val)

  j.options[name] = val
  if (!negate && !val.alias)
    j.result[name] = isList(val) ? 0 : (val.default || false)

  addHelpText(j, name, val)

  // pick up the description and short arg
  if (!negate && !val.alias)
    addFlag(j, `no-${name}`, flag({
      description: `${
        isCount(val) ? 'decrement' : 'switch off'
      } the --${name} flag`,
      hidden: val.hidden,
      implies: val.implies,
      ...(val.negate || {}),
      [_list]: isList(val)
    }))
}

const addHelpText = (j, name, val) => {
  // help text
  if (val.hidden)
    return

  const desc = trim(val.description || (
    val.alias ? `Alias for ${[].concat(val.alias).join(' ')}`
    : '[no description provided]'
  ))
  const mult = isList(val) && !isEnv(val) ? `${
    desc.indexOf('\n') === -1 ? '\n' : '\n\n'
  }Can be set multiple times` : ''
  const text = `${desc}${mult}`

  const hint = val.hint || name
  const shortEq = val.short && val.short.length > 1 ? '=' : ''
  const short = val.short ? (
    isFlag(val) ? `-${val.short} `
    : `-${val.short}${shortEq}<${hint}> `
  ) : ''

  const left = isEnv(val) ? name
    : isFlag(val) ? `${short}--${name}`
    : `${short}--${name}=<${hint}>`

  j.help.push({ text, left })
}

const addShort = (j, name, val) => {
  if (!val.short)
    return

  assertNotDefined(j, val.short)
  val.short !== 'h' || name === 'help' || assert(false,
    `${name} using 'h' short val, reserved for --help`)

  if (isFlag(val))
    j.shortFlags[val.short] = name
  else
    j.shortOpts[val.short] = name
}

const addMain = (j, main) => {
  typeof main === 'function' || assert(false, 'main must be function')
  !j.main || assert(false, 'main function specified multiple times')
  j.main = result => {
    main(result)
    return result
  }
}

const getArgv = j => {
  const argv = [...(j.argv || process.argv)]

  if (argv[0] === process.execPath) {
    argv.shift()
    argv.shift()
  }
  return argv
}

const toNum = (val, key, spec) => {
  !isNaN(val) || assert(false, `non-number '${val}' given for numeric ${key}`)
  val = +val
  isNaN(spec.max) || val <= spec.max || assert(false,
         `value ${val} for ${key} exceeds max (${spec.max})`)
  isNaN(spec.min) || val >= spec.min || assert(false,
         `value ${val} for ${key} below min (${spec.min})`)
  return val
}

const wrap = (text, padding) => {
  const ui = cliui()
  ui.div({ text, padding })
  return ui.toString()
}

const assertValid = (val, key, spec) =>
  !isOpt(spec) || !spec.valid ||
    spec.valid.indexOf(val) !== -1 || assert(false,
      `Invalid value ${val} provided for ${key}.
    Must be one of:
${wrap(spec.valid.join(' '), [ 0, 2, 0, 8 ])}`)

const parse_ = j => {
  const argv = getArgv(j)
  const original = [...argv]

  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]

    if (arg.charAt(0) !== '-' || arg === '-') {
      j.result._.push(arg)
      continue
    }

    if (arg === '--') {
      j.result._ = j.result._.concat(argv.slice(i + 1))
      i = argv.length
      continue
    }

    // short-flags
    if (arg.charAt(1) !== '-') {
      const expand = []
      for (let f = 1; f < arg.length; f++) {
        const fc = arg.charAt(f)
        const sf = j.shortFlags[fc]
        const so = j.shortOpts[fc]
        if (sf)
          expand.push(`--${sf}`)
        else if (so) {
          const soslice = arg.slice(f + 1)
          const soval = !soslice || soslice.charAt(0) === '='
            ? soslice : '=' + soslice

          expand.push(`--${so}${soval}`)
          f = arg.length
        } else if (arg !== `-${fc}`)
          // this will trigger a failure with the appropriate message
          expand.push(`-${fc}`)
      }
      if (expand.length) {
        argv.splice.apply(argv, [i, 1].concat(expand))
        i--
        continue
      }
    }

    const argsplit = arg.split('=')
    const literalKey = argsplit.shift()

    // check if there's a >1 char shortopt/flag for this key,
    // and de-reference it as an alias

    const k = literalKey.replace(/^--?/, '')
    // pick up shorts that aren't single-char
    const key = j.shortOpts[k] || j.shortFlags[k] || k
    let val = argsplit.length ? argsplit.join('=') : null

    const spec = j.options[key]

    spec || assert(false, `invalid argument: ${literalKey}`)
    !isFlag(spec) || val === null || assert(false,
      `value provided for boolean flag: ${key}`)

    if (isOpt(spec) && val === null) {
      val = argv[++i]
      val !== undefined || assert(false,
        `no value provided for option: ${key}`)
    }

    if (spec.alias) {
      const alias = isFlag(spec) ? spec.alias
      : [].concat(spec.alias).map(a => a.replace(/\$\{value\}/g, val))
      argv.splice.apply(argv, [i, 1].concat(alias))
      i--
      continue
    }

    const negate = isFlag(spec) && key.substr(0, 3) === 'no-'
    const name = negate ? key.substr(3) : key
    if (isNum(spec))
      val = toNum(val, `arg ${literalKey}`, spec)

    assertValid(val, key, spec)

    if (isList(spec)) {
      if (isOpt(spec)) {
        if (!Array.isArray(j.result[name]))
          j.result[name] = []
        set(j, name, spec, j.result[name].concat(val))
      } else {
        const v = j.result[name] || 0
        set(j, name, spec, negate ? v - 1 : v + 1)
      }
    } else {
      // either flag or opt
      set(j, name, spec, isFlag(spec) ? !negate : val)
    }
  }

  for (let i in j.implies)
    for (let k in j.implies[i])
      j.result[k] = j.implies[i][k]

  Object.defineProperty(j.result._, 'usage', {
    value: () => console.log(usage(j))
  })
  Object.defineProperty(j.result._, 'update', { value: update(j) })
  Object.defineProperty(j.result._, 'reparse', { value: reparse(j) })
  Object.defineProperty(j.result._, 'explicit', { value: j.explicit })
  Object.defineProperty(j.result._, 'parsed', { value: argv })
  Object.defineProperty(j.result._, 'original', { value: original })

  return j
}

const set = (j, key, spec, val) => {
  j.result[key] = val
  j.explicit.add(key)
  if (spec && spec.implies) {
    if (val === false)
      delete j.implies[key]
    else
      j.implies[key] = {...spec.implies}
  }
  // delete implications about this, since we have an explicit value
  for (const i in j.implies)
    delete j.implies[i][key]
}

// just parse the arguments and return the result
const parse = (...sections) => parse_(buildParser(newObj(), sections)).result

module.exports = { jack, flag, opt, list, count, env, parse, num }
