#!/usr/bin/env node

/**
 * @file
 * The script generates docs.json used as the source of truth
 * for the source code generators (FP, typings, etc.).
 *
 * It's a part of the build process.
 */

const os = require('os')
const pLimit = require('p-limit')
const fs = require('fs/promises')
const path = require('path')
const cloneDeep = require('lodash.clonedeep')
const jsDocParser = require('jsdoc-to-markdown')
const listFns = require('../_lib/listFns')
const docsConfig = require('../../docs/index.js')

const docsPath = path.resolve(process.cwd(), 'tmp/docs.json')

generateDocsFromSource()
  .then(generatedDocsObj)
  .then(injectStaticDocsToDocsObj)
  .then(injectSharedDocsToDocsObj)
  .then(writeDocsFile)
  .catch(reportErrors)

/**
 * Generates docs object from a list of functions using extended JSDoc format.
 */
async function generateDocsFromSource() {
  const fns = await listFns()

  const limit = pLimit(os.cpus().length)

  const configFile = path.resolve(process.cwd(), 'jsdoc2md.json')

  const jobs = fns.map((fn) => {
    return limit(() =>
      jsDocParser
        .getTemplateData({
          files: fn.fullPath,
          'no-cache': true,
          configure: configFile,
        })
        .then((result) => result[0])
    )
  })

  const docsResult = await Promise.all(jobs)

  return docsResult
    .map((doc) => {
      const pureTag =
        doc.customTags && doc.customTags.find((t) => t.tag === 'pure')
      const pure = (pureTag && pureTag.value) !== 'false'
      return {
        type: 'jsdoc',
        kind: 'function',
        urlId: doc.name,
        category: doc.category,
        title: doc.name,
        description: doc.summary,
        content: doc,
        pure,
      }
    })
    .reduce(
      (array, doc) =>
        array
          .concat(generateFnDoc(doc))
          .concat(
            doc.pure
              ? [generateFPFnDoc(doc)].concat(
                  generateFPFnWithOptionsDoc(doc) || []
                )
              : []
          ),
      []
    )
}

/**
 * Generates docs object.
 */
function generatedDocsObj(docs) {
  return groupDocs(docs, docsConfig.groups)
}

/**
 * Injects static docs (markdown documents specified in the config file)
 * to docs object.
 */
function injectStaticDocsToDocsObj(docsFileObj) {
  return getListOfStaticDocs()
    .then((staticDocs) => {
      staticDocs.forEach((staticDoc) => {
        docsFileObj[staticDoc.category].push(staticDoc)
      })
      return docsFileObj
    })
    .catch(reportErrors)
}

/**
 * Injects shared docs to docs object.
 */
function injectSharedDocsToDocsObj(docsFileObj) {
  return generateSharedDocs()
    .then((sharedDocs) => {
      sharedDocs.forEach((sharedDoc) => {
        docsFileObj[sharedDoc.category].push(sharedDoc)
      })
      return docsFileObj
    })
    .catch(reportErrors)
}

/**
 * Prints an error and exits the process with 1 status code.
 */
function reportErrors(err) {
  console.error(err.stack)
  process.exit(1)
}

/**
 * Writes docs file.
 */
function writeDocsFile(docsFileObj) {
  return fs.writeFile(docsPath, JSON.stringify(docsFileObj))
}

/**
 * Groups passed docs list.
 */
function groupDocs(docs, groups) {
  return docs.reduce((acc, doc) => {
    ;(acc[doc.category] = acc[doc.category] || []).push(doc)
    return acc
  }, buildGroupsTemplate(groups))
}

/**
 * Builds an object where the key is a group name and the value is
 * an empty array. Pre-generated docs object allows to preserve the desired
 * groups order.
 */
function buildGroupsTemplate(groups) {
  return groups.reduce((acc, group) => {
    acc[group] = []
    return acc
  }, {})
}

/**
 * Returns promise to list of static docs with its contents.
 */
function getListOfStaticDocs() {
  return Promise.all(
    docsConfig.staticDocs.map((staticDoc) => {
      return fs
        .readFile(staticDoc.path)
        .then((docContent) => docContent.toString())
        .then((content) => Object.assign({ content }, staticDoc))
        .catch(reportErrors)
    })
  )
}

/**
 * Returns promise to list of shared docs with its contents.
 */
function generateSharedDocs() {
  const docs = docsConfig.sharedDocs
    .map(
      (fn) =>
        jsDocParser.getTemplateDataSync({
          files: fn.fullPath,
          'no-cache': true,
        })[0]
    )
    .map((doc) => ({
      type: 'jsdoc',
      kind: 'typedef',
      urlId: doc.name,
      category: doc.category,
      title: doc.name,
      description: doc.summary,
      content: doc,
      properties: paramsToTree(doc.properties),
    }))

  return Promise.resolve(docs)
}

function generateFnDoc(dirtyDoc) {
  const doc = cloneDeep(dirtyDoc)

  const isFPFn = false
  const { urlId, title } = doc
  const args = paramsToTree(doc.content.params)

  return Object.assign(doc, {
    isFPFn,
    args,
    relatedDocs: Object.assign(
      { default: urlId, fp: `fp/${urlId}` },
      withOptions(args) ? { fpWithOptions: `fp/${urlId}WithOptions` } : {}
    ),
    usage: generateUsage(title, isFPFn),
    usageTabs: generateUsageTabs(isFPFn),
    syntax: generateSyntaxString(title, args, isFPFn),
  })
}

function generateFPFnDoc(dirtyDoc) {
  const doc = cloneDeep(dirtyDoc)

  const isFPFn = true
  const { urlId, title } = doc
  const exceptions = doc.content.exceptions.filter(
    (exception) => !exception.description.includes('options.')
  )
  const params = doc.content.params
    .filter((param) => !param.name.startsWith('options'))
    .reverse()
  const args = paramsToTree(params)

  return Object.assign(doc, {
    isFPFn,
    args,
    generatedFrom: title,
    urlId: `fp/${urlId}`,
    relatedDocs: Object.assign(
      { default: urlId, fp: `fp/${urlId}` },
      withOptions(args) ? { fpWithOptions: `fp/${urlId}WithOptions` } : {}
    ),
    usage: generateUsage(title, isFPFn),
    usageTabs: generateUsageTabs(isFPFn),
    syntax: generateSyntaxString(title, args, isFPFn),

    content: Object.assign(doc.content, {
      exceptions,
      params,
      examples:
        'See [FP Guide](https://date-fns.org/docs/FP-Guide) for more information',
    }),
  })
}

function generateFPFnWithOptionsDoc(dirtyDoc) {
  const doc = cloneDeep(dirtyDoc)

  const isFPFn = true
  const { urlId, title } = doc
  const params = doc.content.params
    .map((param) => {
      if (!param.name.includes('.')) {
        param.optional = false
      }
      return param
    })
    .reverse()
  const args = paramsToTree(params)

  if (!withOptions(args)) return

  return Object.assign(doc, {
    isFPFn,
    args,
    generatedFrom: title,
    title: `${title}WithOptions`,
    urlId: `fp/${urlId}WithOptions`,
    relatedDocs: {
      default: urlId,
      fp: `fp/${urlId}`,
      fpWithOptions: `fp/${urlId}WithOptions`,
    },
    usage: generateUsage(title, isFPFn),
    usageTabs: generateUsageTabs(isFPFn),
    syntax: generateSyntaxString(title, args, isFPFn),

    content: Object.assign(doc.content, {
      params,
      id: `${doc.content.id}WithOptions`,
      longname: `${doc.content.longname}WithOptions`,
      name: `${doc.content.name}WithOptions`,
      examples:
        'See [FP Guide](https://date-fns.org/docs/FP-Guide) for more information',
    }),
  })
}

function withOptions(args) {
  return args && args[0].name === 'options'
}

function generateUsageTabs(isFPFn) {
  return isFPFn
    ? ['commonjs', 'es2015', 'esm']
    : ['commonjs', 'umd', 'es2015', 'esm']
}

function generateUsage(name, isFPFn) {
  const submodule = isFPFn ? '/fp' : ''

  let usage = {
    commonjs: {
      title: 'CommonJS',
      code: `var ${name} = require('date-fns${submodule}/${name}')`,
    },

    es2015: {
      title: 'ES 2015',
      code: `import ${name} from 'date-fns${submodule}/${name}'`,
    },

    esm: {
      title: 'ESM',
      code: `import { ${name} } from 'date-fns${
        submodule && `/esm/${submodule}`
      }'`,
      text:
        'See [ECMAScript Modules guide](https://date-fns.org/docs/ECMAScript-Modules) for more information',
    },
  }

  return usage
}

function paramsToTree(dirtyParams) {
  if (!dirtyParams) {
    return null
  }

  const params = cloneDeep(dirtyParams)

  const paramIndices = params.reduce((result, { name }, index) => {
    result[name] = index
    return result
  }, {})

  return params
    .map((param) => {
      const { name, isProperty } = param

      const indexOfDot = name.indexOf('.')

      if (indexOfDot >= 0 && !isProperty) {
        const parentIndex = paramIndices[name.substring(0, indexOfDot)]
        const parent = params[parentIndex]

        param.name = name.substring(indexOfDot + 1)
        param.isProperty = true
        if (!parent.props) {
          parent.props = [param]
        } else {
          parent.props.push(param)
        }
      }

      return param
    })
    .filter((param) => !param.isProperty)
}

function generateSyntaxString(name, args, isFPFn) {
  if (!args) {
    return undefined
  } else if (isFPFn) {
    return args.reduce((acc, arg) => acc.concat(`(${arg.name})`), name)
  } else {
    const argsString = args
      .map((arg) => (arg.optional ? `[${arg.name}]` : arg.name))
      .join(', ')
    return `${name}(${argsString})`
  }
}
