import http from 'http'
import escapeHtml from 'escape-html'
import pump from 'pump'
import rangeParser from 'range-parser'
import queueMicrotask from 'queue-microtask'
import { Readable } from 'streamx'

const keepAliveTime = 20000

class ServerBase {
  constructor (client, opts = {}) {
    this.client = client
    if (!opts.origin) opts.origin = '*' // allow all origins by default
    this.opts = opts
    this.pendingReady = new Set()
  }

  static serveIndexPage (res, torrents, pathname) {
    const listHtml = torrents
      .map(torrent => (
      `<li>
        <a href="${escapeHtml(pathname)}/${torrent.infoHash}">
          ${escapeHtml(torrent.name)}
        </a>
        (${escapeHtml(torrent.length)} bytes)
      </li>`
      ))
      .join('<br>')

    res.status = 200
    res.headers['Content-Type'] = 'text/html'
    res.body = getPageHTML(
      'WebTorrent',
      `<h1>WebTorrent</h1>
       <ol>${listHtml}</ol>`
    )

    return res
  }

  isOriginAllowed (req) {
    // When `origin` option is `false`, deny all cross-origin requests
    if (this.opts.origin === false) return false

    // The user allowed all origins
    if (this.opts.origin === '*') return true

    // Allow requests where the 'Origin' header matches the `opts.origin` setting
    return req.headers.origin === this.opts.origin
  }

  static serveMethodNotAllowed (res) {
    res.status = 405
    res.headers['Content-Type'] = 'text/html'

    res.body = getPageHTML(
      '405 - Method Not Allowed',
      '<h1>405 - Method Not Allowed</h1>'
    )

    return res
  }

  static serve404Page (res) {
    res.status = 404
    res.headers['Content-Type'] = 'text/html'

    res.body = getPageHTML(
      '404 - Not Found',
      '<h1>404 - Not Found</h1>'
    )
    return res
  }

  static serveTorrentPage (torrent, res, pathname) {
    const listHtml = torrent.files
      .map(file => (
      `<li>
        <a href="${escapeHtml(pathname)}/${torrent.infoHash}/${escapeHtml(file.path)}">
          ${escapeHtml(file.path)}
        </a>
        (${escapeHtml(file.length)} bytes)
      </li>`
      ))
      .join('<br>')

    res.status = 200
    res.headers['Content-Type'] = 'text/html'

    res.body = getPageHTML(
      `${escapeHtml(torrent.name)} - WebTorrent`,
      `<h1>${escapeHtml(torrent.name)}</h1>
      <ol>${listHtml}</ol>`
    )

    return res
  }

  static serveOptionsRequest (req, res) {
    res.status = 204 // no content
    res.headers['Access-Control-Max-Age'] = '600'
    res.headers['Access-Control-Allow-Methods'] = 'GET,HEAD'

    if (req.headers['access-control-request-headers']) {
      res.headers['Access-Control-Allow-Headers'] = req.headers['access-control-request-headers']
    }
    return res
  }

  static serveFile (file, req, res) {
    res.status = 200

    // Disable caching as data is local anyways
    res.headers.Expires = '0'
    res.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
    // Support range-requests
    res.headers['Accept-Ranges'] = 'bytes'
    res.headers['Content-Type'] = file.type
    // Support DLNA streaming
    res.headers['transferMode.dlna.org'] = 'Streaming'
    res.headers['contentFeatures.dlna.org'] = 'DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000'

    // Force the browser to download the file if if it's opened in a new tab
    // Set name of file (for "Save Page As..." dialog)
    if (req.destination === 'document') {
      res.headers['Content-Type'] = 'application/octet-stream'
      res.headers['Content-Disposition'] = `attachment; filename*=UTF-8''${encodeRFC5987(file.name)}`
      res.body = 'DOWNLOAD'
    } else {
      res.headers['Content-Disposition'] = `inline; filename*=UTF-8''${encodeRFC5987(file.name)}`
    }

    // `rangeParser` returns an array of ranges, or an error code (number) if
    // there was an error parsing the range.
    let range = rangeParser(file.length, req.headers.range || '')

    if (Array.isArray(range)) {
      res.status = 206 // indicates that range-request was understood

      // no support for multi-range request, just use the first range
      range = range[0]

      res.headers['Content-Range'] = `bytes ${range.start}-${range.end}/${file.length}`

      res.headers['Content-Length'] = range.end - range.start + 1
    } else {
      res.statusCode = 200
      range = null
      res.headers['Content-Length'] = file.length
    }

    if (req.method === 'GET') {
      const iterator = file[Symbol.asyncIterator](range)
      let transform = null
      file.emit('iterator', { iterator, req, file }, target => {
        transform = target
      })

      const stream = Readable.from(transform || iterator)
      let pipe = null
      file.emit('stream', { stream, req, file }, target => {
        pipe = pump(stream, target)
      })

      res.body = pipe || stream
    } else {
      res.body = false
    }
    return res
  }

  async onRequest (req, cb) {
    let pathname = new URL(req.url, 'http://example.com').pathname
    pathname = pathname.slice(pathname.indexOf(this.pathname) + this.pathname.length + 1)

    const res = {
      headers: {
        // Prevent browser mime-type sniffing
        'X-Content-Type-Options': 'nosniff',
        // Defense-in-depth: Set a strict Content Security Policy to mitigate XSS
        'Content-Security-Policy': "base-uri 'none'; frame-ancestors 'none'; form-action 'none';"
      }
    }

    // Allow cross-origin requests (CORS)
    if (this.isOriginAllowed(req)) {
      res.headers['Access-Control-Allow-Origin'] = this.opts.origin === '*' ? '*' : req.headers.origin
    }

    if (pathname === 'favicon.ico') {
      return cb(ServerBase.serve404Page(res))
    }

    // Allow CORS requests to specify arbitrary headers, e.g. 'Range',
    // by responding to the OPTIONS preflight request with the specified
    // origin and requested headers.
    if (req.method === 'OPTIONS') {
      if (this.isOriginAllowed(req)) return cb(ServerBase.serveOptionsRequest(req, res))
      else return cb(ServerBase.serveMethodNotAllowed(res))
    }

    const onReady = async () => {
      this.pendingReady.delete(onReady)
      const res = await handleRequest()
      cb(res)
    }

    const handleRequest = async () => {
      if (pathname === '') {
        return ServerBase.serveIndexPage(res, this.client.torrents, this.pathname)
      }

      let [infoHash, ...filePath] = pathname.split('/')
      filePath = decodeURI(filePath.join('/'))

      const torrent = await this.client.get(infoHash)
      if (!infoHash || !torrent) {
        return ServerBase.serve404Page(res)
      }

      if (!filePath) {
        return ServerBase.serveTorrentPage(torrent, res, this.pathname)
      }

      const file = torrent.files.find(file => file.path.replace(/\\/g, '/') === filePath)
      if (!file) {
        return ServerBase.serve404Page(res)
      }
      return ServerBase.serveFile(file, req, res)
    }

    if (req.method === 'GET' || req.method === 'HEAD') {
      if (this.client.ready) {
        const res = await handleRequest()
        return cb(res)
      } else {
        this.pendingReady.add(onReady)
        this.client.once('ready', onReady)
        return
      }
    }

    return cb(ServerBase.serveMethodNotAllowed(res))
  }

  close (cb = () => {}) {
    this.closed = true
    this.pendingReady.forEach(onReady => {
      this.client.removeListener('ready', onReady)
    })
    this.pendingReady.clear()
    queueMicrotask(cb)
  }

  destroy (cb = () => {}) {
    // Only call `server.close` if user has not called it already
    if (this.closed) queueMicrotask(cb)
    else this.close(cb)
    this.client = null
  }
}

class NodeServer extends ServerBase {
  constructor (client, opts) {
    super(client, opts)

    this.server = http.createServer()
    this._listen = this.server.listen
    this.server.listen = this.listen.bind(this)
    this._close = this.server.close
    this.server.close = this.close.bind(this)

    this.sockets = new Set()
    this.closed = false
    this.pathname = opts?.pathname || '/webtorrent'
  }

  wrapRequest (req, res) {
    // If a 'hostname' string is specified, deny requests with a 'Host'
    // header that does not match the origin of the torrent server to prevent
    // DNS rebinding attacks.
    if (this.opts.hostname && req.headers.host !== `${this.opts.hostname}:${this.server.address().port}`) {
      return req.destroy()
    }

    if (!new URL(req.url, 'http://example.com').pathname.startsWith(this.pathname)) {
      return req.destroy()
    }

    this.onRequest(req, ({ status, headers, body }) => {
      res.writeHead(status, headers)

      if (!!body._readableState || !!body._writableState) { // this is probably a bad way of checking? idk
        pump(body, res)
      } else {
        res.end(body)
      }
    })
  }

  onConnection (socket) {
    socket.setTimeout(36000000)
    this.sockets.add(socket)
    socket.once('close', () => {
      this.sockets.delete(socket)
    })
  }

  address () {
    return this.server.address()
  }

  listen (...args) {
    this.closed = false
    this.server.on('connection', this.onConnection.bind(this))
    this.server.on('request', this.wrapRequest.bind(this))
    return this._listen.apply(this.server, args)
  }

  close (cb = () => {}) {
    this.server.removeAllListeners('connection')
    this.server.removeAllListeners('request')
    this.server.removeAllListeners('listening')
    super.close()
    this._close.call(this.server, cb)
  }

  destroy (cb) {
    this.sockets.forEach(socket => {
      socket.destroy()
    })
    super.destroy(cb)
  }
}

class BrowserServer extends ServerBase {
  constructor (client, opts) {
    super(client, opts)

    this.registration = opts.controller
    this.workerKeepAliveInterval = null
    this.workerPortCount = 0

    const scope = new URL(opts.controller.scope)
    this.pathname = scope.pathname + 'webtorrent'
    this._address = {
      port: scope.port,
      family: 'IPv4', // might be a bad idea?
      address: scope.hostname
    }

    this.boundHandler = this.wrapRequest.bind(this)
    navigator.serviceWorker.addEventListener('message', this.boundHandler)
    // test if browser supports cancelling sw Readable Streams
    fetch(`${this.pathname}/cancel/`).then(res => {
      res.body.cancel()
    })
  }

  wrapRequest (event) {
    const req = event.data

    if (!req?.type === 'webtorrent' || !req.url) return null

    const [port] = event.ports
    this.onRequest(req, ({ status, headers, body }) => {
      const asyncIterator = body[Symbol.asyncIterator]?.()

      const cleanup = () => {
        port.onmessage = null
        if (body?.destroy) body.destroy()
        this.workerPortCount--
        if (!this.workerPortCount) {
          clearInterval(this.workerKeepAliveInterval)
          this.workerKeepAliveInterval = null
        }
      }

      port.onmessage = async msg => {
        if (msg.data) {
          let chunk
          try {
            chunk = (await asyncIterator.next()).value
          } catch (e) {
            // chunk is yet to be downloaded or it somehow failed, should this be logged?
          }
          port.postMessage(chunk)
          if (!chunk) cleanup()
          if (!this.workerKeepAliveInterval) {
            this.workerKeepAliveInterval = setInterval(() => fetch(`${this.pathname}/keepalive/`), keepAliveTime)
          }
        } else {
          cleanup()
        }
      }
      this.workerPortCount++
      port.postMessage({
        status,
        headers,
        body: asyncIterator ? 'STREAM' : body
      })
    })
  }

  // for compatibility with node version
  listen (_, cb) {
    cb()
  }

  address () {
    return this._address
  }

  close (cb) {
    navigator.serviceWorker.removeEventListener('message', this.boundHandler)
    super.close(cb)
  }

  destroy (cb) {
    super.destroy(cb)
  }
}

// NOTE: Arguments must already be HTML-escaped
function getPageHTML (title, pageHtml) {
  return `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <title>${title}</title>
      </head>
      <body>
        ${pageHtml}
      </body>
    </html>
  `
}

// From https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
function encodeRFC5987 (str) {
  return encodeURIComponent(str)
    // Note that although RFC3986 reserves "!", RFC5987 does not,
    // so we do not need to escape it
    .replace(/['()]/g, escape) // i.e., %27 %28 %29
    .replace(/\*/g, '%2A')
    // The following are not required for percent-encoding per RFC5987,
    // so we can allow for a little better readability over the wire: |`^
    .replace(/%(?:7C|60|5E)/g, unescape)
}

export { NodeServer, BrowserServer }
