'use strict'

const assert = require('assert')
const recorder = require('./recorder')
const {
  activate,
  disableNetConnect,
  enableNetConnect,
  removeAll: cleanAll,
} = require('./intercept')
const { loadDefs, define } = require('./scope')

const { format } = require('util')
const path = require('path')
const debug = require('debug')('nock.back')

let _mode = null

let fs

try {
  fs = require('fs')
} catch (err) {
  // do nothing, probably in browser
}

/**
 * nock the current function with the fixture given
 *
 * @param {string}   fixtureName  - the name of the fixture, e.x. 'foo.json'
 * @param {object}   options      - [optional] extra options for nock with, e.x. `{ assert: true }`
 * @param {function} nockedFn     - [optional] callback function to be executed with the given fixture being loaded;
 *                                  if defined the function will be called with context `{ scopes: loaded_nocks || [] }`
 *                                  set as `this` and `nockDone` callback function as first and only parameter;
 *                                  if not defined a promise resolving to `{nockDone, context}` where `context` is
 *                                  aforementioned `{ scopes: loaded_nocks || [] }`
 *
 * List of options:
 *
 * @param {function} before       - a preprocessing function, gets called before nock.define
 * @param {function} after        - a postprocessing function, gets called after nock.define
 * @param {function} afterRecord  - a postprocessing function, gets called after recording. Is passed the array
 *                                  of scopes recorded and should return the array scopes to save to the fixture
 * @param {function} recorder     - custom options to pass to the recorder
 *
 */
function Back(fixtureName, options, nockedFn) {
  if (!Back.fixtures) {
    throw new Error(
      'Back requires nock.back.fixtures to be set\n' +
        'Ex:\n' +
        "\trequire(nock).back.fixtures = '/path/to/fixtures/'"
    )
  }

  if (typeof fixtureName !== 'string') {
    throw new Error('Parameter fixtureName must be a string')
  }

  if (arguments.length === 1) {
    options = {}
  } else if (arguments.length === 2) {
    // If 2nd parameter is a function then `options` has been omitted
    // otherwise `options` haven't been omitted but `nockedFn` was.
    if (typeof options === 'function') {
      nockedFn = options
      options = {}
    }
  }

  _mode.setup()

  const fixture = path.join(Back.fixtures, fixtureName)
  const context = _mode.start(fixture, options)

  const nockDone = function() {
    _mode.finish(fixture, options, context)
  }

  debug('context:', context)

  // If nockedFn is a function then invoke it, otherwise return a promise resolving to nockDone.
  if (typeof nockedFn === 'function') {
    nockedFn.call(context, nockDone)
  } else {
    return Promise.resolve({ nockDone, context })
  }
}

/*******************************************************************************
 *                                    Modes                                     *
 *******************************************************************************/

const wild = {
  setup: function() {
    cleanAll()
    recorder.restore()
    activate()
    enableNetConnect()
  },

  start: function() {
    return load() // don't load anything but get correct context
  },

  finish: function() {
    // nothing to do
  },
}

const dryrun = {
  setup: function() {
    recorder.restore()
    cleanAll()
    activate()
    //  We have to explicitly enable net connectivity as by default it's off.
    enableNetConnect()
  },

  start: function(fixture, options) {
    const contexts = load(fixture, options)

    enableNetConnect()
    return contexts
  },

  finish: function() {
    // nothing to do
  },
}

const record = {
  setup: function() {
    recorder.restore()
    recorder.clear()
    cleanAll()
    activate()
    disableNetConnect()
  },

  start: function(fixture, options) {
    if (!fs) {
      throw new Error('no fs')
    }
    const context = load(fixture, options)

    if (!context.isLoaded) {
      recorder.record({
        dont_print: true,
        output_objects: true,
        ...options.recorder,
      })

      context.isRecording = true
    }

    return context
  },

  finish: function(fixture, options, context) {
    if (context.isRecording) {
      let outputs = recorder.outputs()

      if (typeof options.afterRecord === 'function') {
        outputs = options.afterRecord(outputs)
      }

      outputs =
        typeof outputs === 'string' ? outputs : JSON.stringify(outputs, null, 4)
      debug('recorder outputs:', outputs)

      fs.mkdirSync(path.dirname(fixture), { recursive: true })
      fs.writeFileSync(fixture, outputs)
    }
  },
}

const lockdown = {
  setup: function() {
    recorder.restore()
    recorder.clear()
    cleanAll()
    activate()
    disableNetConnect()
  },

  start: function(fixture, options) {
    return load(fixture, options)
  },

  finish: function() {
    // nothing to do
  },
}

function load(fixture, options) {
  const context = {
    scopes: [],
    assertScopesFinished: function() {
      assertScopes(this.scopes, fixture)
    },
  }

  if (fixture && fixtureExists(fixture)) {
    let scopes = loadDefs(fixture)
    applyHook(scopes, options.before)

    scopes = define(scopes)
    applyHook(scopes, options.after)

    context.scopes = scopes
    context.isLoaded = true
  }

  return context
}

function applyHook(scopes, fn) {
  if (!fn) {
    return
  }

  if (typeof fn !== 'function') {
    throw new Error('processing hooks must be a function')
  }

  scopes.forEach(fn)
}

function fixtureExists(fixture) {
  if (!fs) {
    throw new Error('no fs')
  }

  return fs.existsSync(fixture)
}

function assertScopes(scopes, fixture) {
  const pending = scopes
    .filter(scope => !scope.isDone())
    .map(scope => scope.pendingMocks())

  if (pending.length) {
    assert.fail(
      format(
        '%j was not used, consider removing %s to rerecord fixture',
        [].concat(...pending),
        fixture
      )
    )
  }
}

const Modes = {
  wild, // all requests go out to the internet, dont replay anything, doesnt record anything

  dryrun, // use recorded nocks, allow http calls, doesnt record anything, useful for writing new tests (default)

  record, // use recorded nocks, record new nocks

  lockdown, // use recorded nocks, disables all http calls even when not nocked, doesnt record
}

Back.setMode = function(mode) {
  if (!(mode in Modes)) {
    throw new Error(`Unknown mode: ${mode}`)
  }

  Back.currentMode = mode
  debug('New nock back mode:', Back.currentMode)

  _mode = Modes[mode]
  _mode.setup()
}

Back.fixtures = null
Back.currentMode = null

module.exports = Back