const fs = require('fs')
const path = require('path')
const Handlebars = require('handlebars')
const hasOwnProperty = require('./utils').hasOwnProperty

const ENTRY_FOLDER = path.join(__dirname, '../src/entry')

const IGNORED_DEPENDENCIES = {
  on: true,
  config: true,
  math: true,
  mathWithTransform: true,
  classes: true
}

const DEPRECATED_FACTORIES = {
  typeof: 'typeOf',
  var: 'variance',
  eval: 'evaluate',
  E: 'e',
  PI: 'pi'
}

const FACTORY_NAMES_ES6_MAPPING = {
  true: '_true',
  false: '_false',
  NaN: '_NaN',
  null: '_null',
  Infinity: '_Infinity'
}

const IGNORED_DEPENDENCIES_ES6 = {
  on: true
}

const dependenciesIndexTemplate = Handlebars.compile(`/**
 * THIS FILE IS AUTO-GENERATED
 * DON'T MAKE CHANGES HERE
 */
{{#factories}}
export { {{name}} } from '{{fileName}}'{{eslintComment}}
{{/factories}}

export { all } from './allFactories{{suffix}}.js'
`)

const dependenciesFileTemplate = Handlebars.compile(`/**
 * THIS FILE IS AUTO-GENERATED
 * DON'T MAKE CHANGES HERE
 */
{{#dependencies}}
import { {{name}} } from '{{fileName}}'
{{/dependencies}}
import { {{factoryName}} } from '../../factories{{suffix}}.js'{{eslintComment}}

export const {{name}} = {{braceOpen}}{{eslintComment}}
  {{#dependencies}}
  {{name}},
  {{/dependencies}}
  {{factoryName}}
}
`)

const pureFunctionsTemplate = Handlebars.compile(`/**
 * THIS FILE IS AUTO-GENERATED
 * DON'T MAKE CHANGES HERE
 */
import { config } from './configReadonly'
import {
  {{#pureFactories}}
  {{factoryName}}{{#unless @last}},{{/unless}}{{eslintComment}}
  {{/pureFactories}}
} from '../factories{{suffix}}'

{{#pureFactories}}
export const {{name}} = /* #__PURE__ */ {{factoryName ~}}
({{braceOpen}}{{#if dependencies}} {{/if ~}}
{{#dependencies ~}}
{{name}}{{#unless @last}}, {{/unless ~}}
{{/dependencies ~}}
{{#if dependencies}} {{/if ~}}})
{{/pureFactories}}
`)

const impureFunctionsTemplate = Handlebars.compile(`/**
 * THIS FILE IS AUTO-GENERATED
 * DON'T MAKE CHANGES HERE
 */
import { config } from './configReadonly'
import {
  {{#impureFactories}}
  {{factoryName}},{{eslintComment}}
  {{/impureFactories}}
  {{#transformFactories}}
  {{factoryName}}{{#unless @last}},{{/unless}}{{eslintComment}}
  {{/transformFactories}}
} from '../factories{{suffix}}'
import {
  {{#pureFactories}}
  {{name}}{{#unless @last}},{{/unless}}{{eslintComment}}
  {{/pureFactories}}
} from './pureFunctions{{suffix}}.generated'

const math = {} // NOT pure!
const mathWithTransform = {} // NOT pure!
const classes = {} // NOT pure!

{{#impureFactories}}
export const {{name}} = {{factoryName ~}}
({{braceOpen}}{{#if dependencies}} {{/if ~}}
{{#dependencies ~}}
{{name}}{{#unless @last}}, {{/unless ~}}
{{/dependencies ~}}
{{#if dependencies}} {{/if ~}}})
{{/impureFactories}}

Object.assign(math, {
{{#math}}
{{#if renamed}}
  '{{name}}': {{renamed}},
{{else if mappedName}}
  {{name}}: {{mappedName}},
{{else}}
  {{name}},
{{/if}}
{{/math}}
  config
})

Object.assign(mathWithTransform, math, {
  {{#transformFactories}}
  {{name}}: {{factoryName ~}}
  ({{braceOpen}}{{#if dependencies}} {{/if ~}}
  {{#dependencies ~}}
  {{name}}{{#unless @last}}, {{/unless ~}}
  {{/dependencies ~}}
  {{#if dependencies}} {{/if ~}}}){{#unless @last}},{{/unless}}
  {{/transformFactories}}
})

Object.assign(classes, {
{{#classes}}
  {{name}}{{#unless @last}},{{/unless}}
{{/classes}}
})

Chain.createProxy(math)

export { embeddedDocs as docs } from '../expression/embeddedDocs/embeddedDocs'
`)

exports.generateEntryFiles = function () {
  const factoriesAny = require('../lib/factoriesAny')
  const factoriesNumber = require('../lib/factoriesNumber')

  generateDependenciesFiles({
    suffix: 'Any',
    factories: factoriesAny,
    entryFolder: ENTRY_FOLDER
  })

  generateDependenciesFiles({
    suffix: 'Number',
    factories: factoriesNumber,
    entryFolder: ENTRY_FOLDER
  })

  generateFunctionsFiles({
    suffix: 'Any',
    factories: factoriesAny,
    entryFolder: ENTRY_FOLDER
  })

  generateFunctionsFiles({
    suffix: 'Number',
    factories: factoriesNumber,
    entryFolder: ENTRY_FOLDER
  })
}

/**
 * Generate index files like
 *   dependenciesAny.generated.js
 *   dependenciesNumber.generated.js
 * And the individual files for every dependencies collection.
 */
function generateDependenciesFiles ({ suffix, factories, entryFolder }) {
  const braceOpen = '{' // a hack to be able to create a single brace open character in handlebars

  // a map containing:
  // {
  //   'sqrt': true,
  //   'subset': true,
  //   ...
  // }
  const exists = {}
  Object.keys(factories).forEach(factoryName => {
    const factory = factories[factoryName]
    exists[factory.fn] = true
  })

  mkdirSyncIfNotExists(path.join(entryFolder, 'dependencies' + suffix))

  const data = {
    suffix,
    factories: Object.keys(factories).map((factoryName) => {
      const factory = factories[factoryName]

      return {
        suffix,
        factoryName,
        braceOpen,
        name: getDependenciesName(factoryName, factories), // FIXME: rename name with dependenciesName, and functionName with name
        fileName: './dependencies' + suffix + '/' + getDependenciesFileName(factoryName) + '.generated',
        eslintComment: factoryName === 'createSQRT1_2'
          ? ' // eslint-disable-line camelcase'
          : undefined,

        dependencies: factory.dependencies
          .map(stripOptionalNotation)
          .filter(dependency => !IGNORED_DEPENDENCIES[dependency])
          .filter(dependency => {
            if (!exists[dependency]) {
              if (factory.dependencies.indexOf(dependency) !== -1) {
                throw new Error(`Required dependency "${dependency}" missing for factory "${factory.fn}" (suffix: ${suffix})`)
              }

              return false
            }

            return true
          })
          .map(dependency => {
            const factoryName = findFactoryName(factories, dependency)
            const name = getDependenciesName(factoryName, factories)
            const fileName = './' + getDependenciesFileName(factoryName) + '.generated'

            return {
              suffix,
              name,
              fileName
            }
          })
      }
    })
  }

  // generate a file for every dependency
  data.factories.forEach(factoryData => {
    const generatedFactory = dependenciesFileTemplate(factoryData)

    const p = path.join(entryFolder, factoryData.fileName + '.js')
    fs.writeFileSync(p, generatedFactory)
  })

  // generate a file with links to all dependencies
  const generated = dependenciesIndexTemplate(data)
  fs.writeFileSync(path.join(entryFolder, 'dependencies' + suffix + '.generated.js'), generated)
}

/**
 * Generate index files like
 *   pureFunctionsAny.generated.js
 *   impureFunctionsAny.generated.js
 */
function generateFunctionsFiles ({ suffix, factories, entryFolder }) {
  const braceOpen = '{' // a hack to be able to create a single brace open character in handlebars

  const sortedFactories = sortFactories(values(factories))

  // sort the factories, and split them in three groups:
  // - transform: the transform functions
  // - impure: the functions that depend on `math` or `mathWithTransform` (directly or indirectly),
  // - pure: the rest
  const pureFactories = []
  const impureFactories = []
  const transformFactories = []
  sortedFactories
    .filter(factory => !DEPRECATED_FACTORIES[factory.fn])
    .forEach(factory => {
      if (isTransform(factory)) {
        transformFactories.push(factory)
      } else if (
        contains(factory.dependencies, 'math') ||
        contains(factory.dependencies, 'mathWithTransform') ||
        contains(factory.dependencies, 'classes') ||
        isTransform(factory) ||
        factory.dependencies.some(dependency => {
          return impureFactories.find(f => f.fn === stripOptionalNotation(dependency))
        })
      ) {
        impureFactories.push(factory)
      } else {
        pureFactories.push(factory)
      }
    })

  // a map containing:
  // {
  //   'sqrt': true,
  //   'subset': true,
  //   ...
  // }
  const pureExists = {
    config: true
  }
  pureFactories.forEach(factory => {
    pureExists[factory.fn] = true
  })

  const impureExists = {
    ...pureExists
  }
  impureFactories.forEach(factory => {
    impureExists[factory.fn] = true
  })

  const math = sortedFactories
    .filter(factory => !isClass(factory))
    .filter(factory => !isTransform(factory))
    .map(factory => {
      return {
        name: factory.fn,
        renamed: DEPRECATED_FACTORIES[factory.fn],
        mappedName: FACTORY_NAMES_ES6_MAPPING[factory.fn]
      }
    })

  const classes = sortedFactories
    .filter(factory => isClass(factory))
    .map(factory => ({ name: factory.fn }))

  const data = {
    suffix,
    pureFactories: pureFactories.map(factory => {
      const name = FACTORY_NAMES_ES6_MAPPING[factory.fn] || factory.fn

      return {
        braceOpen,
        factoryName: findKey(factories, factory), // TODO: find a better way to match the factory names
        eslintComment: name === 'SQRT1_2'
          ? ' // eslint-disable-line camelcase'
          : undefined,
        name,
        dependencies: factory.dependencies
          .map(stripOptionalNotation)
          .filter(dependency => !IGNORED_DEPENDENCIES_ES6[dependency])
          .filter(dependency => {
            // TODO: this code is duplicated. extract it in a separate function
            if (!pureExists[dependency]) {
              if (factory.dependencies.indexOf(dependency) !== -1) {
                throw new Error(`Required dependency "${dependency}" missing for factory "${factory.fn}" (suffix: ${suffix})`)
              }

              return false
            }

            return true
          })
          .map(dependency => ({ name: dependency }))
      }
    }),

    impureFactories: impureFactories.map(factory => {
      const name = FACTORY_NAMES_ES6_MAPPING[factory.fn] || factory.fn

      return {
        braceOpen,
        factoryName: findKey(factories, factory), // TODO: find a better way to match the factory names
        eslintComment: name === 'SQRT1_2'
          ? ' // eslint-disable-line camelcase'
          : undefined,
        name,
        dependencies: factory.dependencies
          .map(stripOptionalNotation)
          .filter(dependency => !IGNORED_DEPENDENCIES_ES6[dependency])
          .filter(dependency => {
            if (dependency === 'math' || dependency === 'mathWithTransform' || dependency === 'classes') {
              return true
            }

            // TODO: this code is duplicated. extract it in a separate function
            if (!impureExists[dependency]) {
              // if (factory.dependencies.indexOf(dependency) !== -1) {
              //   throw new Error(`Required dependency "${dependency}" missing for factory "${factory.fn}"`)
              // }

              return false
            }

            return true
          })
          .map(dependency => ({ name: dependency }))
      }
    }),

    transformFactories: transformFactories.map(factory => {
      const name = FACTORY_NAMES_ES6_MAPPING[factory.fn] || factory.fn

      return {
        braceOpen,
        factoryName: findKey(factories, factory), // TODO: find a better way to match the factory names
        eslintComment: name === 'SQRT1_2'
          ? ' // eslint-disable-line camelcase'
          : undefined,
        name,
        dependencies: factory.dependencies
          .map(stripOptionalNotation)
          .filter(dependency => !IGNORED_DEPENDENCIES_ES6[dependency])
          .filter(dependency => {
            if (dependency === 'math' || dependency === 'mathWithTransform' || dependency === 'classes') {
              return true
            }

            // TODO: this code is duplicated. extract it in a separate function
            if (!impureExists[dependency]) {
              // if (factory.dependencies.indexOf(dependency) !== -1) {
              //   throw new Error(`Required dependency "${dependency}" missing for factory "${factory.fn}"`)
              // }

              return false
            }

            return true
          })
          .map(dependency => ({ name: dependency }))
      }
    }),

    math,
    classes
  }

  // create file with all functions: functionsAny.generated.js, functionsNumber.generated.js
  fs.writeFileSync(path.join(entryFolder, 'pureFunctions' + suffix + '.generated.js'), pureFunctionsTemplate(data))

  // create file with all functions: impureFunctions.generated.js, impureFunctions.generated.js
  fs.writeFileSync(path.join(entryFolder, 'impureFunctions' + suffix + '.generated.js'), impureFunctionsTemplate(data))
}

function getDependenciesName (factoryName, factories) {
  if (!factories) {
    throw new Error('Cannot create dependencies name: factories is undefined')
  }

  const factory = factories[factoryName]
  const transform = isTransform(factory) ? 'Transform' : ''

  return factory.fn + transform + 'Dependencies'
}

function getDependenciesFileName (factoryName) {
  if (factoryName.indexOf('create') !== 0) {
    throw new Error(`Cannot create dependencies name from factoryName "${factoryName}". Should start with "create..."`)
  }

  return 'dependencies' + factoryName.slice(6)
}

function findFactoryName (factories, name) {
  for (const factoryName in factories) {
    if (hasOwnProperty(factories, factoryName)) {
      if (factories[factoryName].fn === name) {
        return factoryName
      }
    }
  }

  return undefined
}

function findKey (object, value) {
  for (const key in object) {
    if (hasOwnProperty(object, key)) {
      if (object[key] === value) {
        return key
      }
    }
  }

  return undefined
}

/**
 * Sort the factories in such a way that their dependencies are resolved.
 * @param {Array} factories
 * @return {Array} Returns sorted factories
 */
exports.sortFactories = sortFactories

function sortFactories (factories) {
  const loaded = {}
  const leftOverFactories = factories.slice()
  const sortedFactories = []

  const exists = {}
  factories.forEach(factory => {
    exists[factory.fn] = true
  })

  function allDependenciesResolved (dependencies) {
    return dependencies.every(dependency => {
      const d = stripOptionalNotation(dependency)
      return loaded[d] === true ||
        IGNORED_DEPENDENCIES[d] === true ||
        (dependency[0] === '?' && !exists[d])
    })
  }

  let changed = true
  while (leftOverFactories.length > 0 && changed) {
    changed = false

    for (let i = 0; i < leftOverFactories.length; i++) {
      const factory = leftOverFactories[i]
      if (allDependenciesResolved(factory.dependencies)) {
        if (!isTransform(factory)) {
          loaded[factory.fn] = true
        }
        sortedFactories.push(factory)
        leftOverFactories.splice(i, 1)
        changed = true
      }
    }
  }

  if (leftOverFactories.length > 0) {
    const first = leftOverFactories[0]
    throw new Error('Cannot resolve dependencies of factory "' + first.fn + '". ' +
      'Dependencies: ' + first.dependencies.map(d => '"' + d + '"').join(', '))
  }

  return sortedFactories
}

function values (object) {
  return Object.keys(object).map(key => object[key])
}

function contains (array, item) {
  return array.indexOf(item) !== -1
}

function isTransform (factory) {
  return (factory && factory.meta && factory.meta.isTransformFunction === true) || false
}

function isClass (factory) {
  return (factory && factory.meta && factory.meta.isClass === true) || false
}

function stripOptionalNotation (dependency) {
  return dependency && dependency[0] === '?'
    ? dependency.slice(1)
    : dependency
}

// function will check if a directory exists, and create it if it doesn't
// https://blog.raananweber.com/2015/12/15/check-if-a-directory-exists-in-node-js/
function mkdirSyncIfNotExists (directory) {
  try {
    fs.statSync(directory)
  } catch (error) {
    fs.mkdirSync(directory)
  }
}
