#!/usr/bin/env node

/**
 * This script imports raw files from https://github.com/octokit/routes, formats them as yaml and moves
 * to correct locations.
 * As the contents in octokit/routes are still being updated from time to time, this script will allow
 * us to update files here later again.
 */

const fs = require('fs')
const path = require('path')

const chalk = require('chalk').default
const yaml = require('js-yaml')
const yargs = require('yargs')
const { isEqual } = require('lodash')
const jsonmergepatch = require('json-merge-patch')
const glob = require('glob')

const schemaNames = require('./schemaNames')

const { readYaml, saveYaml, getYamlComment, addYamlComment, changeExtension, sha } = require('../utils/utils')

const cli = yargs
  .help('h')
  .alias('h', ['help', 'usage'])
  .usage('$0 <octokit-routes-path>')

const {
  _: [octokitPath]
} = cli.argv

if (!octokitPath) {
  console.log('You must provide path to the octokit/routes folder')
  console.log('\nUsage:')
  cli.showHelp()
  process.exit(1)
}

if (!fs.existsSync(octokitPath)) {
  console.log(`${chalk.blue(octokitPath)} does not exist`)
  console.log('\nUsage:')
  cli.showHelp()
  process.exit(1)
}

const openapiPath = path.join(octokitPath, 'openapi')

if (fs.existsSync(octokitPath) && !fs.existsSync(openapiPath)) {
  console.log(`${chalk.blue(octokitPath)} does not exist. Provide path to the root of the octokit/routes folder`)
  console.log('\nUsage:')
  cli.showHelp()
  process.exit(1)
}

// Enough validation

const OUTPUT_PATH = 'definitions'
const apis = fs.readdirSync(openapiPath)
const OVERLAYS_KEY = 'x-githubOverlays'

const API_NAME = {
  'ghe-2.15': 'GitHub Enterprise',
  'ghe-2.16': 'GitHub Enterprise Server',
  'ghe-2.17': 'GitHub Enterprise Server',
  'ghe-2.18': 'GitHub Enterprise Server',
  'ghe-2.19': 'GitHub Enterprise Server',
  'ghe-2.20': 'GitHub Enterprise Server',
  'api.github.com': 'GitHub'
}

const API_NAME_REGEXP = {
  'ghe-2.15': 'GitHub Enterprise(?!\\s+(authentication|instance))',
  'ghe-2.16': 'GitHub Enterprise Server',
  'ghe-2.17': 'GitHub Enterprise Server',
  'ghe-2.18': 'GitHub Enterprise Server',
  'ghe-2.19': 'GitHub Enterprise Server',
  'ghe-2.20': 'GitHub Enterprise Server',
  // There are lots of unnecessary replacements when using just "GitHub" for replacing.
  // I crafted manually the scary regexp below to omit replacing the most common cases
  // Everything this regexp misses can be edited out later manually.
  'api.github.com':
    "(?<!within |Triggers |specific areas of )GitHub(?!\\s*(Enterprise|App|account|Help|Pro|Team|Free|Actions|automatically|will write|adds|Flavored|\\.com|, it must be an ASCII|'s|REST|login|identifies|recommends|users?))"
}

// current state for deduplication
const prevVersionSchemaToFile = {}
let currentSchemaToFile = {}
let usedWithoutPatchInCurrentVersion = {}

for (const api of apis) {
  const apiPath = path.join(openapiPath, api)

  const srcOpenApiRootFile = path.join(apiPath, 'index.json')
  const openapiRoot = readYaml(srcOpenApiRootFile)
  if (!openapiRoot.openapi) {
    throw new Error(`Not an OpenAPI file: ${path.join(apiPath, 'index.json')}`)
  }

  const renamedOperationFiles = {}

  const operationsPath = path.join(apiPath, 'operations')
  for (const operationGroup of fs.readdirSync(operationsPath)) {
    for (const operationFile of fs.readdirSync(path.join(operationsPath, operationGroup))) {
      const srcOpenApiOperationFile = path.join(operationsPath, operationGroup, operationFile)
      const openapiOperation = readYamlAndReplacePlaceholders(srcOpenApiOperationFile, api)

      // we generate code samples during build so no need to keep them for manual editing
      delete openapiOperation['x-codeSamples']
      delete openapiOperation['x-code-samples']

      extractParameters(openapiOperation)
      extractCommonSchemas(openapiOperation, api)

      const destOpenApiOperationFile = path.join(
        OUTPUT_PATH,
        'operations',
        operationGroup,
        changeExtension(operationFile, '.yml')
      )

      saveOrUpdateYamlWithOverlays(destOpenApiOperationFile, openapiOperation, api)
      console.log(`Imported ${chalk.blue(srcOpenApiOperationFile)} to ${chalk.blue(destOpenApiOperationFile)}`)
    }
  }

  fixOpenApi$Refs(openapiRoot, renamedOperationFiles)
  injectPlaceholders(openapiRoot, api)
  const destOpenApiRootFile = path.join(OUTPUT_PATH, `${api}.yml`)
  saveYaml(destOpenApiRootFile, openapiRoot)

  console.log(`${chalk.redBright(srcOpenApiRootFile)} -> ${chalk.green(destOpenApiRootFile)}`)

  Object.assign(prevVersionSchemaToFile, currentSchemaToFile)
  currentSchemaToFile = {}
  usedWithoutPatchInCurrentVersion = {}
}

// simplify overlays

const allYamls = glob.sync('definitions/**/*.yml')

for (const filename of allYamls) {
  const data = readYaml(filename)
  const overlays = data[OVERLAYS_KEY]
  if (!overlays) continue

  const overlayHashToVersion = {}

  for (const version in overlays) {
    const hash = sha(JSON.stringify(overlays[version]))
    overlayHashToVersion[hash] = overlayHashToVersion[hash] || []
    overlayHashToVersion[hash].push(version)
  }

  const variations = Object.values(overlayHashToVersion)
  if (variations.length === Object.keys(overlays).length) {
    continue // all good already
  }

  // these scripts will be removed soon so we just hardcode some variants here

  if (variations.length === 1 && variations[0].length === 4) {
    data[OVERLAYS_KEY] = {
      '<public': overlays[variations[0][0]]
    }
  } else {
    const newOverlays = {}
    for (const versions of variations) {
      let semver = versions.join(' || ')

      if (semver === '2.16 || 2.17 || 2.18') {
        semver = '<=2.18'
      }

      newOverlays[semver] = overlays[versions[0]]
    }
    data[OVERLAYS_KEY] = newOverlays
  }

  const oldComment = getYamlComment(filename)
  saveYaml(filename, data, oldComment)
}

/**
 * Fixes $ref's from .json to .yml.
 *
 * @param {object} openapi - OpenAPI object.
 * @param {object} renamedOperationFiles - Maping of renamed files.
 */
function fixOpenApi$Refs (openapi, renamedOperationFiles) {
  for (const operationPath of Object.keys(openapi.paths)) {
    for (const httpVerb of Object.keys(openapi.paths[operationPath])) {
      const operation = openapi.paths[operationPath][httpVerb]
      if (operation && renamedOperationFiles[operation.$ref]) {
        operation.$ref = renamedOperationFiles[operation.$ref]
      } else if (operation && operation.$ref && operation.$ref.endsWith('.json')) {
        operation.$ref = operation.$ref.replace(/\.json$/, '.yml')
      }
    }
  }
}

function extractParameters (operation) {
  if (!operation.parameters) return
  for (let i = 0; i < operation.parameters.length; i++) {
    const param = operation.parameters[i]
    const paramName = param.name
    const paramFilePath = path.join(OUTPUT_PATH, 'components', 'parameters', `${paramName}.yml`)
    if (fs.existsSync(paramFilePath)) {
      if (!isEqual(readYaml(paramFilePath), param)) {
        continue
      }
    } else {
      saveYaml(paramFilePath, param)
    }

    operation.parameters[i] = {
      $ref: `../../components/parameters/${paramName}.yml`
    }
  }
}

function extractCommonSchemas (operation, api) {
  extractCommonResponseSchemas(operation, api)
}

function extractCommonResponseSchemas (operation, api) {
  const responsesCodes = Object.keys(operation.responses || {})
  for (const code of responsesCodes) {
    if (parseInt(code, 10) > 299) continue
    if (operation.responses[code].content) {
      const mimeTypes = operation.responses[code].content
      for (const mimeName of Object.keys(mimeTypes)) {
        const mime = mimeTypes[mimeName]
        if (!mime.schema) continue

        let schemaName = schemaNames[operation.operationId]
        if (!schemaName) continue

        if (schemaName === 'Deployment' && code === '202') {
          schemaName = 'JobNoUrl'
        }

        if (mimeName.endsWith('star+json')) {
          schemaName += '+Star'
        }

        let schema = mime.schema
        let schemaParent = mime
        let schemaKey = 'schema'

        const props = schema.properties || {}
        const propNames = Object.keys(props)
        if (props.total_count) {
          if (propNames.length === 2 || (propNames.length === 3 && props.incomplete_results)) {
            schemaParent = props
            schemaKey = propNames.find(p => p !== 'total_count' && p !== 'incomplete_results')
            schema = props[schemaKey]
          }
        }

        if (schema.type === 'array') {
          schemaParent = schema
          schemaKey = 'items'
          schema = schema.items
        }

        const schemaFile = saveSchemaToFile(schemaName, schema, operation.operationId, api)

        schemaParent[schemaKey] = {
          $ref: `../../${path.relative(OUTPUT_PATH, schemaFile)}`
        }
      }
    }
  }
}

function isEqualAfterMerge (a, b, apiVersion) {
  a = JSON.parse(JSON.stringify(a))
  b = JSON.parse(JSON.stringify(b))

  if (a[OVERLAYS_KEY] && a[OVERLAYS_KEY][apiVersion]) {
    jsonmergepatch.apply(a, a[OVERLAYS_KEY][apiVersion])
  }
  delete a[OVERLAYS_KEY]

  if (b[OVERLAYS_KEY] && b[OVERLAYS_KEY][apiVersion]) {
    jsonmergepatch.apply(b, b[OVERLAYS_KEY][apiVersion])
  }
  delete b[OVERLAYS_KEY]

  return isEqual(a, b)
}

function saveOrUpdateYamlWithOverlays (filename, value, apiName, comment) {
  if (!fs.existsSync(filename)) {
    saveYaml(filename, value, comment)
    return
  }

  const apiVersion = apiName === 'api.github.com' ? 'api.github.com' : apiName.replace('ghe-', '')

  const diskValue = readYaml(filename)
  if (isEqualAfterMerge(diskValue, value, apiVersion)) {
    addYamlComment(filename, comment)
    return
  }

  const oldMerge = diskValue[OVERLAYS_KEY]
  delete diskValue[OVERLAYS_KEY]
  const merge = jsonmergepatch.generate(diskValue, value)
  diskValue[OVERLAYS_KEY] = oldMerge || {}
  diskValue[OVERLAYS_KEY][apiVersion] = merge

  const oldComment = getYamlComment(filename)
  saveYaml(filename, diskValue, oldComment)
  addYamlComment(filename, comment)
}

/**
 * Save model schema to file trying to reuse existing schemas.
 * If the schema already exists and is different suffix filename with version
 * e.g. `User-1.yml`. If `apiName` is one of ghe- tries saving with `-ghe` suffix.
 *
 * @param {string} name - Model name.
 * @param {object} schema - Model object.
 * @param {string} operationId - Operation ID.
 * @param {string} apiVersion - API name.
 *
 * @returns {string} - Filename of the saved schema model.
 */
function saveSchemaToFile (name, schema, operationId, apiVersion) {
  let version = 1
  let apiSuffix = ''
  let schemaFile = path.join(OUTPUT_PATH, 'components', 'schemas', `${name}.yml`)

  let getSchemaFileName = () => {
    const versionSuffix = version === 1 ? '' : `-${version}`
    const schemaName = `${name}${versionSuffix}${apiSuffix}`
    return path.join(OUTPUT_PATH, 'components', 'schemas', `${schemaName}.yml`)
  }

  const schemaId = `${operationId}__${name}`

  if (prevVersionSchemaToFile[schemaId]) {
    // write to the same file for the schema used in the same operation in previous version
    schemaFile = prevVersionSchemaToFile[schemaId]
    getSchemaFileName = () => prevVersionSchemaToFile[schemaId]
  }

  // Try reusing existing schema model
  while (fs.existsSync(schemaFile)) {
    schemaFile = getSchemaFileName()
    if (fs.existsSync(schemaFile)) {
      const diskSchema = readYaml(schemaFile)
      if (isEqualAfterMerge(diskSchema, schema, apiVersion)) {
        addYamlComment(schemaFile, `Used in ${apiVersion}: ${operationId}`)
        usedWithoutPatchInCurrentVersion[schemaFile] = true
        currentSchemaToFile[schemaId] = schemaFile
        // console.log(`${chalk.green('reused')} ${path.basename(schemaFile)} at ${operationId}`)
        return schemaFile
      } else if (
        apiVersion !== 'api.github.com' &&
        !usedWithoutPatchInCurrentVersion[schemaFile] &&
        (!diskSchema[OVERLAYS_KEY] || !diskSchema[OVERLAYS_KEY][apiVersion])
      ) {
        saveOrUpdateYamlWithOverlays(schemaFile, schema, apiVersion, `Used in ${apiVersion}: ${operationId}`)
        currentSchemaToFile[schemaId] = schemaFile
        return schemaFile
      }
    }

    version++

    if (apiVersion !== 'api.github.com' && !fs.existsSync(getSchemaFileName())) {
      if (!apiSuffix) {
        apiSuffix = '-ghe'
        version = 1
      }
    }
  }

  console.log(`${chalk.green('saved')} ${path.basename(schemaFile)} at ${operationId}`)
  saveYaml(schemaFile, schema, `Used in ${apiVersion}: ${operationId}`)
  currentSchemaToFile[schemaId] = schemaFile
  return schemaFile
}

function injectPlaceholders (openapiRoot, apiName) {
  // remove and insert paths later just to keep nice ordering in yaml
  const paths = openapiRoot.paths
  delete openapiRoot.paths

  openapiRoot['x-githubPlaceholders'] = {
    externalDocsUrl: getDocsUrl(apiName),
    apiName: API_NAME[apiName],
    version: apiName
  }

  openapiRoot.paths = paths
}

// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
function escapeRegExp (str) {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
}

function readYamlAndReplacePlaceholders (filename, api) {
  const operation = yaml.safeLoad(fs.readFileSync(filename, 'utf-8'))

  const docsUrl = getDocsUrl(api)
  const docUrlRegExp = new RegExp(escapeRegExp(docsUrl), 'g')

  const apiNameRegExp = new RegExp(API_NAME_REGEXP[api], 'g')

  const crawl = obj => {
    if (typeof obj !== 'object' || obj == null) {
      return
    }
    for (const k of Object.keys(obj)) {
      if (typeof obj[k] === 'string') {
        obj[k] = obj[k]
          .replace(docUrlRegExp, '${externalDocsUrl}') // eslint-disable-line
          .replace(apiNameRegExp, '${apiName}') // eslint-disable-line
          .replace('${externalDocsUrl}/changes', `${docsUrl}/changes`) // eslint-disable-line
      } else {
        crawl(obj[k])
      }
    }
  }

  crawl(operation)

  return operation
}

function getDocsUrl (api) {
  return `https://developer.github.com${api === 'api.github.com' ? '' : `/enterprise/${api.substr(4)}`}`
}
