#!/usr/bin/env node

const path = require('path')
const fs = require('fs')
const yaml = require('yaml')
const cmark = require('cmark-gfm')
const mdx = require('@mdx-js/mdx')
const mkdirp = require('mkdirp')
const jsdom = require('jsdom')
const npm = require('../lib/npm.js')

const config = require('./config.json')

const docsRoot = __dirname
const inputRoot = path.join(docsRoot, 'content')
const outputRoot = path.join(docsRoot, 'output')

const template = fs.readFileSync('template.html').toString()

const run = async function () {
  try {
    const navPaths = await getNavigationPaths()
    const fsPaths = await renderFilesystemPaths()

    if (!ensureNavigationComplete(navPaths, fsPaths))
      process.exit(1)
  } catch (error) {
    console.error(error)
  }
}

run()

function ensureNavigationComplete (navPaths, fsPaths) {
  const unmatchedNav = { }; const unmatchedFs = { }

  for (const navPath of navPaths)
    unmatchedNav[navPath] = true

  for (let fsPath of fsPaths) {
    fsPath = '/' + fsPath.replace(/\.md$/, '')

    if (unmatchedNav[fsPath])
      delete unmatchedNav[fsPath]

    else
      unmatchedFs[fsPath] = true
  }

  const missingNav = Object.keys(unmatchedNav).sort()
  const missingFs = Object.keys(unmatchedFs).sort()

  if (missingNav.length > 0 || missingFs.length > 0) {
    let message = 'Error: documentation navigation (nav.yml) does not match filesystem.\n'

    if (missingNav.length > 0) {
      message += '\nThe following path(s) exist on disk but are not present in nav.yml:\n\n'

      for (const nav of missingNav)
        message += `  ${nav}\n`
    }

    if (missingNav.length > 0 && missingFs.length > 0) {
      message += '\nThe following path(s) exist in nav.yml but are not present on disk:\n\n'

      for (const fs of missingFs)
        message += `  ${fs}\n`
    }

    message += '\nUpdate nav.yml to ensure that all files are listed in the appropriate place.'

    console.error(message)

    return false
  }

  return true
}

function getNavigationPaths () {
  const navFilename = path.join(docsRoot, 'nav.yml')
  const nav = yaml.parse(fs.readFileSync(navFilename).toString(), 'utf8')

  return walkNavigation(nav)
}

function walkNavigation (entries) {
  const paths = []

  for (const entry of entries) {
    if (entry.children)
      paths.push(...walkNavigation(entry.children))

    else
      paths.push(entry.url)
  }

  return paths
}

async function renderFilesystemPaths () {
  return await walkFilesystem(inputRoot)
}

async function walkFilesystem (root, dirRelative) {
  const paths = []

  const dirPath = dirRelative ? path.join(root, dirRelative) : root
  const children = fs.readdirSync(dirPath)

  for (const childFilename of children) {
    const childRelative = dirRelative ?
      path.join(dirRelative, childFilename) :
      childFilename
    const childPath = path.join(root, childRelative)

    if (fs.lstatSync(childPath).isDirectory())
      paths.push(...await walkFilesystem(root, childRelative))

    else {
      await renderFile(childRelative)
      paths.push(childRelative)
    }
  }

  return paths
}

async function renderFile (childPath) {
  const inputPath = path.join(inputRoot, childPath)

  if (!inputPath.match(/\.md$/)) {
    console.log(`warning: unknown file type ${inputPath}, ignored`)
    return
  }

  const outputPath = path.join(outputRoot, childPath.replace(/\.md$/, '.html'))

  let md = fs.readFileSync(inputPath).toString()
  let frontmatter = { }

  // Take the leading frontmatter out of the markdown
  md = md.replace(/^---\n([\s\S]+)\n---\n/, (header, fm) => {
    frontmatter = yaml.parse(fm, 'utf8')
    return ''
  })

  // Replace any tokens in the source
  md = md.replace(/@VERSION@/, npm.version)

  // Render the markdown into an HTML snippet using a GFM renderer.
  const content = cmark.renderHtmlSync(md, {
    smart: true,
    githubPreLang: true,
    strikethroughDoubleTilde: true,
    unsafe: false,
    extensions: {
      table: true,
      strikethrough: true,
      tagfilter: true,
      autolink: true,
    },
  })

  // Test that mdx can parse this markdown file.  We don't actually
  // use the output, it's just to ensure that the upstream docs
  // site (docs.npmjs.com) can parse it when this file gets there.
  try {
    await mdx(md, { skipExport: true })
  } catch (error) {
    throw new MarkdownError(childPath, error)
  }

  // Inject this data into the template, using a mustache-like
  // replacement scheme.
  const html = template.replace(/{{\s*([\w.]+)\s*}}/g, (token, key) => {
    switch (key) {
      case 'content':
        return `<div id="_content">${content}</div>`
      case 'path':
        return childPath
      case 'url_path':
        return encodeURI(childPath)

      case 'toc':
        return '<div id="_table_of_contents"></div>'

      case 'title':
      case 'section':
      case 'description':
        return frontmatter[key]

      case 'config.github_repo':
      case 'config.github_branch':
      case 'config.github_path':
        return config[key.replace(/^config\./, '')]

      default:
        console.log(`warning: unknown token '${token}' in ${inputPath}`)
        return ''
    }
  })

  const dom = new jsdom.JSDOM(html)
  const document = dom.window.document

  // Rewrite relative URLs in links and image sources to be relative to
  // this file; this is for supporting `file://` links.  HTML pages need
  // suffix appended.
  const links = [
    { tag: 'a', attr: 'href', suffix: '.html' },
    { tag: 'img', attr: 'src' },
  ]

  for (const linktype of links) {
    for (const tag of document.querySelectorAll(linktype.tag)) {
      let url = tag.getAttribute(linktype.attr)

      if (url.startsWith('/')) {
        const childDepth = childPath.split('/').length - 1
        const prefix = childDepth > 0 ? '../'.repeat(childDepth) : './'

        url = url.replace(/^\//, prefix)

        if (linktype.suffix)
          url += linktype.suffix

        tag.setAttribute(linktype.attr, url)
      }
    }
  }

  // Give headers a unique id so that they can be linked within the doc
  const headerIds = []
  for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
    if (header.getAttribute('id')) {
      headerIds.push(header.getAttribute('id'))
      continue
    }

    const headerText = header.textContent.replace(/[A-Z]/g, x => x.toLowerCase()).replace(/ /g, '-').replace(/[^a-z0-9-]/g, '')
    let headerId = headerText
    let headerIncrement = 1

    while (document.getElementById(headerId) !== null)
      headerId = headerText + (++headerIncrement)

    headerIds.push(headerId)
    header.setAttribute('id', headerId)
  }

  // Walk the dom and build a table of contents
  const toc = document.getElementById('_table_of_contents')

  if (toc)
    toc.appendChild(generateTableOfContents(document))

  // Write the final output
  const output = dom.serialize()

  mkdirp.sync(path.dirname(outputPath))
  fs.writeFileSync(outputPath, output)
}

function generateTableOfContents (document) {
  const headers = []
  walkHeaders(document.getElementById('_content'), headers)

  // The nesting depth of headers are not necessarily the header level.
  // (eg, h1 > h3 > h5 is a depth of three even though there's an h5.)
  const hierarchy = []
  for (const header of headers) {
    const level = headerLevel(header)

    while (
      hierarchy.length &&
      hierarchy[hierarchy.length - 1].headerLevel > level
    )
      hierarchy.pop()

    if (
      !hierarchy.length ||
      hierarchy[hierarchy.length - 1].headerLevel < level
    ) {
      const newList = document.createElement('ul')
      newList.headerLevel = level

      if (hierarchy.length)
        hierarchy[hierarchy.length - 1].appendChild(newList)

      hierarchy.push(newList)
    }

    const element = document.createElement('li')

    const link = document.createElement('a')
    link.setAttribute('href', `#${header.getAttribute('id')}`)
    link.innerHTML = header.innerHTML
    element.appendChild(link)

    const list = hierarchy[hierarchy.length - 1]
    list.appendChild(element)
  }

  return hierarchy[0]
}

function walkHeaders (element, headers) {
  for (const child of element.childNodes) {
    if (headerLevel(child))
      headers.push(child)

    walkHeaders(child, headers)
  }
}

function headerLevel (node) {
  const level = node.tagName ? node.tagName.match(/^[Hh]([123456])$/) : null
  return level ? level[1] : 0
}

class MarkdownError extends Error {
  constructor (file, inner) {
    super(`failed to parse ${file}`)
    this.file = file
    this.inner = inner
  }
}
