#!/bin/bash
// >&/dev/null; exec node --harmony-async-await $0 $@
// vi:syntax=javascript

// Native
const path = require('path')
const {parse} = require('url')

// Packages
const {StringDecoder} = require('string_decoder')
const micro = require('micro')
const {red} = require('chalk')
const args = require('args')
const {compile} = require('handlebars')
const {isBinarySync} = require('istextorbinary')
const filesize = require('filesize')
const mime = require('mime-types')
const compress = require('micro-compress')
const fs = require('fs-promise')
const auth = require('basic-auth')
const stream = require('send')

args
  .option('port', 'Port to listen on', process.env.PORT)
  .option('cache', 'How long static files should be cached in the browser (seconds)', 3600)
  .option('single', 'Serve single page apps with only one index.html')
  .option('unzipped', 'Disable GZIP compression')
  .option('ignore', 'Files and directories to ignore', '')
  .option('auth', 'Serve behind basic auth')
  .option(['o', 'cors'], 'Setup * CORS headers to allow requests from any origin', false)

const flags = args.parse(process.argv)
const directory = args.sub[0]

process.env.ASSET_DIR = '/' + Math.random().toString(36).substr(2, 10)

let current = process.cwd()

if (directory) {
  current = path.resolve(process.cwd(), directory)
}

const isDir = async dir => {
  let stats

  try {
    stats = await fs.stat(dir)
  } catch (err) {
    return false
  }

  if (stats.isDirectory()) {
    return true
  }

  return false
}

const prepareView = () => {
  let viewContent = false
  const viewPath = path.normalize(path.join(__dirname, '/../views/index.hbs'))

  try {
    viewContent = fs.readFileSync(viewPath, 'utf8')
  } catch (err) {
    throw err
  }

  return compile(viewContent)
}

let ignoredFiles = [
  '.DS_Store',
  '.git/'
]

if (flags.ignore && flags.ignore.length > 0) {
  ignoredFiles = ignoredFiles.concat(flags.ignore.split(','))
}

const renderDirectory = async dir => {
  let files = []
  const subPath = path.relative(current, dir)

  if (!fs.existsSync(dir)) {
    return false
  }

  try {
    files = await fs.readdir(dir)
  } catch (err) {
    throw err
  }

  for (const file of files) {
    const filePath = path.resolve(dir, file)
    const index = files.indexOf(file)
    const details = path.parse(filePath)

    details.relative = path.join(subPath, details.base)

    if (await isDir(filePath)) {
      details.base += '/'
    } else {
      details.ext = details.ext.split('.')[1] || 'txt'

      let fileStats

      try {
        fileStats = await fs.stat(filePath)
      } catch (err) {
        throw err
      }

      details.size = filesize(fileStats.size, {round: 0})
    }

    if (ignoredFiles.indexOf(details.base) > -1) {
      delete files[index]
    } else {
      files[files.indexOf(file)] = details
    }
  }

  const directory = path.join(path.basename(current), subPath, '/')
  const pathParts = directory.split('/')

  if (dir.indexOf(current + '/') > -1) {
    const directoryPath = [...pathParts]
    directoryPath.shift()

    files.unshift({
      base: '..',
      relative: path.join(...directoryPath, '..')
    })
  }

  const render = prepareView()

  const paths = []
  pathParts.pop()

  for (const part in pathParts) {
    if (!{}.hasOwnProperty.call(pathParts, part)) {
      continue
    }

    let before = 0
    const parents = []

    while (before <= part) {
      parents.push(pathParts[before])
      before++
    }

    parents.shift()

    paths.push({
      name: pathParts[part],
      url: parents.join('/')
    })
  }

  const details = {
    port: flags.port,
    files,
    assetDir: process.env.ASSET_DIR,
    directory,
    nodeVersion: process.version.split('v')[1],
    paths
  }

  return render(details)
}

const handler = async (req, res) => {
  if (flags.auth) {
    const credentials = auth(req)

    if (!process.env.SERVE_USER || !process.env.SERVE_PASSWORD) {
      console.error(red('You are running serve with basic auth but did not set the SERVE_USER and SERVE_PASSWORD environment variables.'))
      process.exit(1)
    }

    if (!credentials || credentials.name !== process.env.SERVE_USER || credentials.pass !== process.env.SERVE_PASSWORD) {
      res.statusCode = 401
      res.setHeader('WWW-Authenticate', 'Basic realm="User Visible Realm"')
      return micro.send(res, 401, 'Access Denied')
    }
  }

  const {pathname} = parse(req.url)
  let related = path.parse(path.join(current, pathname))

  if (related.dir.indexOf(process.env.ASSET_DIR) > -1) {
    const relative = path.relative(process.env.ASSET_DIR, pathname)
    related = path.parse(path.join(__dirname, '/../assets', relative))
  }

  related = decodeURIComponent(path.format(related))

  let notFoundResponse = 'Not Found'

  try {
    const custom404Path = path.join(current, '/404.html')
    notFoundResponse = await fs.readFile(custom404Path, 'utf-8')
  } catch (err) {}

  if (!fs.existsSync(related) && flags.single === undefined) {
    return micro.send(res, 404, notFoundResponse)
  }

  // Check if file or directory path
  if (await isDir(related)) {
    let indexPath = path.join(related, '/index.html')

    res.setHeader('Content-Type', mime.contentType(path.extname(indexPath)))

    if (!fs.existsSync(indexPath)) {
      // Try to render the current directory's content
      const renderedDir = await renderDirectory(related)

      // If it works, send the directory listing to the user
      if (renderedDir) {
        return micro.send(res, 200, renderedDir)
      }

      // And if it doesn't, see if it's a single page application
      // If that's not true either, send an error
      if (!flags.single) {
        return micro.send(res, 404, notFoundResponse)
      }

      // But IF IT IS true, load the SPA's root index file
      indexPath = path.join(current, '/index.html')
    }

    let indexContent

    try {
      indexContent = await fs.readFile(indexPath, 'utf8')
    } catch (err) {
      throw err
    }

    return micro.send(res, 200, indexContent)
  }

  if (!fs.existsSync(related) && flags.single) {
    const indexPath = path.join(current, '/index.html')
    let indexContent

    try {
      indexContent = await fs.readFile(indexPath, 'utf8')
    } catch (err) {
      return micro.send(res, 404, notFoundResponse)
    }

    return micro.send(res, 200, indexContent)
  }

  let body = 'Not able to load file!'
  let stats

  try {
    body = await fs.readFile(related)
    stats = await fs.stat(related)
  } catch (err) {
    if (err instanceof RangeError) {
      return stream(req, related).pipe(res)
    }

    throw err
  }

  const binaryStat = isBinarySync(path.parse(related).base, body)
  const getETag = s => '"' + s.dev + '-' + s.ino + '-' + s.mtime.getTime() + '"'

  let requestDate = req.headers['if-modified-since']
  let statusCode = 200

  if (requestDate) {
    requestDate = new Date(requestDate)

    if (requestDate.getTime() === stats.mtime.getTime()) {
      statusCode = 304
    }
  }

  const defaultHeaders = {
    'Cache-Control': 'public, max-age=' + flags.cache,
    Pragma: 'public',
    ETag: getETag(stats)
  }

  // Using same --cors behavior as in http-server:
  // https://github.com/indexzero/http-server/blob/fed98f2dbb87f1ea7a793e48a1975c20c9e970fa/lib/http-server.js#L68
  if (flags.cors) {
    defaultHeaders['Access-Control-Allow-Origin'] = '*'
    defaultHeaders['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, Range'
  }

  for (const header in defaultHeaders) {
    if (!{}.hasOwnProperty.call(defaultHeaders, header)) {
      continue
    }

    res.setHeader(header, defaultHeaders[header])
  }

  if (binaryStat) {
    res.statusCode = statusCode
    res.end(body, 'binary')
  } else {
    const decoder = new StringDecoder('utf8')
    const contentType = mime.contentType(path.extname(related)) || mime.contentType('text')

    res.setHeader('Content-Type', contentType)
    micro.send(res, statusCode, decoder.write(body))
  }
}

const server = flags.unzipped ? micro(handler) : micro(compress(handler))

server.listen(flags.port, async () => {
  const details = server.address()

  process.on('SIGINT', () => {
    server.close()
    process.exit(0)
  })

  if (!await isDir(current)) {
    console.error(red('Specified directory doesn\'t exist!'))
    process.exit(1)
  }

  if (!process.env.NOW) {
    console.log(`Running on http://localhost:${details.port}`)
  }
})
