/* eslint no-shadow:2 */
'use strict'

const { execSync } = require('child_process')
const semver = require('semver')
const fs = require('fs')
const config = require('@npmcli/template-oss')
const { resolve, relative } = require('path')

const exec = (...args) => execSync(...args).toString().trim()
const today = () => {
  const d = new Date()
  const pad = s => s.toString().padStart(2, '0')
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}

const usage = () => `
  node ${relative(process.cwd(), __filename)} [--read|-r] [--write|-w] [tag]

  Generates changelog entries in our format starting from the most recent tag.
  By default this script will print the release notes to stdout.

  [tag] (defaults to most recent tag)
  A tag to generate release notes for. Helpful for testing this script against
  old releases. Leave this empty to look for the most recent tag.

  [--write|-w] (default: false)
  When set it will update the changelog with the new release.
  If a release with the same version already exists it will replace it, otherwise
  it will prepend it to the file directly after the top level changelog title.

  [--read|-r] (default: false)
  When set it will read the release notes for the [tag] from the CHANGELOG.md,
  instead of fetching it. This is useful after release notes have been manually
  edited and need to be pasted somewhere else.
`

// this script assumes that the tags it looks for all start with this prefix
const TAG_PREFIX = 'v'

// a naive implementation of console.log/group for indenting console
// output but keeping it in a buffer to be output to a file or console
const logger = (init) => {
  let indent = 0
  const step = 2
  const buffer = [init]
  return {
    toString () {
      return buffer.join('\n').trim()
    },
    group (s) {
      this.log(s)
      indent += step
    },
    groupEnd () {
      indent -= step
    },
    log (s) {
      if (!s) {
        buffer.push('')
      } else {
        buffer.push(s.split('\n').map((l) => ' '.repeat(indent) + l).join('\n'))
      }
    },
  }
}

// some helpers for generating common parts
// of our markdown release notes
const RELEASE = {
  sep: '\n\n',
  heading: '## ',
  // versions in titles must be prefixed with a v
  versionRe: semver.src[11].replace(TAG_PREFIX + '?', TAG_PREFIX),
  get h1 () {
    return '# Changelog' + this.sep
  },
  version (s) {
    return s.startsWith(TAG_PREFIX) ? s : TAG_PREFIX + s
  },
  date (d) {
    return `(${d})`
  },
  title (v, d) {
    return `${this.heading}${this.version(v)} ${this.date(d)}`
  },
}

// a map of all our changelog types that go into the release notes to be
// looked up by commit type and return the section title
const CHANGELOG = new Map(
  config.changelogTypes.filter(c => !c.hidden).map((c) => [c.type, c.section]))

const assertArgs = (args) => {
  if (args.help) {
    console.log(usage())
    return process.exit(0)
  }

  if (args.force) {
    // just to make manual testing easier
    return args
  }

  // we dont need to be up to date to read from our local changelog
  if (!args.read) {
    exec(`git fetch ${args.remote}`)
    const remoteBranch = `${args.remote}/${args.branch}`
    const current = exec(`git rev-parse --abbrev-ref HEAD`)

    if (current !== args.branch) {
      throw new Error(`Must be on branch "${args.branch}", rerun with --force to override`)
    }

    const localLog = exec(`git log ${remoteBranch}..HEAD`).length > 0
    const remoteLog = exec(`git log HEAD..${remoteBranch}`).length > 0

    if (current !== args.branch || localLog || remoteLog) {
      throw new Error(`Must be in sync with "${remoteBranch}"`)
    }
  }

  return args
}

const parseArgs = (argv) => {
  const result = {
    tag: null,
    file: resolve(__dirname, '..', 'CHANGELOG.md'),
    branch: 'latest',
    remote: 'origin',
    type: 'md', // or 'gh'
    format: 'short', // or 'long'
    write: false,
    read: false,
    help: false,
    force: false,
  }

  for (const arg of argv) {
    if (arg.startsWith('--')) {
      // dash to camel case. no value means boolean true
      const [key, value = true] = arg.slice(2).split('=')
      result[key.replace(/-([a-z])/g, (a) => a[1].toUpperCase())] = value
    } else if (arg.startsWith('-')) {
      // shorthands for read and write
      const short = arg.slice(1)
      const key = short === 'w' ? 'write' : short === 'r' ? 'read' : null
      result[key] = true
    } else {
      // anything else without a -- or - is the tag
      // force tag to start with a "v"
      result.tag = arg.startsWith(TAG_PREFIX) ? arg : TAG_PREFIX + arg
    }
  }

  // previous tag to requested tag OR most recent tag and everything after
  // only matches tags prefixed with "v" since only the cli is prefixed with v
  const getTag = (t = '') => exec(['git', 'describe', '--tags', '--abbrev=0',
    `--match="${TAG_PREFIX}*" ${t}`].join(' '))

  return assertArgs({
    ...result,
    // if a tag is passed in get the previous tag to make a range between the two
    // this is mostly for testing to generate release notes from old releases
    startTag: result.tag ? getTag(`${result.tag}~1`) : getTag(),
    endTag: result.tag || '',
  })
}

// find an entire section of a release from the changelog from a version
const findRelease = (args, version) => {
  const changelog = fs.readFileSync(args.file, 'utf-8')
  const escRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

  const titleSrc = (v) => [
    '^',
    RELEASE.heading,
    v ? escRegExp(v) : RELEASE.versionRe,
    ' ',
    escRegExp(RELEASE.date(today())).replace(/\d/g, '\\d'),
    '$',
  ].join('')

  const releaseSrc = [
    '(',
    titleSrc(version && RELEASE.version(version)),
    '[\\s\\S]*?',
    RELEASE.sep,
    ')',
    titleSrc(),
  ].join('')

  const [, release = ''] = changelog.match(new RegExp(releaseSrc, 'm')) || []
  return {
    release: release.trim(),
    changelog,
  }
}

const generateRelease = async (args) => {
  const range = `${args.startTag}...${args.endTag}`

  const log = exec(`git log --reverse --pretty='format:%h' ${range}`)
    .split('\n')
    .filter(Boolean)
    // prefix with underscore so its always a valid identifier
    .map((sha) => `_${sha}: object (expression: "${sha}") { ...commitCredit }`)

  if (!log.length) {
    throw new Error(`No commits found for "${range}"`)
  }

  const query = `
    fragment commitCredit on GitObject {
      ... on Commit {
        message
        url
        abbreviatedOid
        authors (first:10) {
          nodes {
            user {
              login
              url
            }
            email
            name
          }
        }
        associatedPullRequests (first:10) {
          nodes {
            number
            url
            merged
          }
        }
      }
    }

    query {
      repository (owner:"npm", name:"cli") {
        ${log}
      }
    }
  `

  const res = JSON.parse(exec(`gh api graphql -f query='${query}'`))

  // collect commits by valid changelog type
  const commits = [...CHANGELOG.values()].reduce((acc, c) => {
    acc[c] = []
    return acc
  }, {})

  const allCommits = Object.values(res.data.repository)

  for (const commit of allCommits) {
    // get changelog type of commit or bail if there is not a valid one
    const [, type] = /(^\w+)[\s(:]?/.exec(commit.message) || []
    const changelogType = CHANGELOG.get(type)
    if (!changelogType) {
      continue
    }

    const message = commit.message
      .trim() // remove leading/trailing spaces
      .replace(/(\r?\n)+/gm, '\n') // replace multiple newlines with one
      .replace(/([^\s]+@\d+\.\d+\.\d+.*)/gm, '`$1`') // wrap package@version in backticks

    // the title is the first line of the commit, 'let' because we change it later
    let [title, ...body] = message.split('\n')

    const prs = commit.associatedPullRequests.nodes.filter((pull) => pull.merged)

    // external squashed PRs dont get the associated pr node set
    // so we try to grab it from the end of the commit title
    // since thats where it goes by default
    const [, titleNumber] = title.match(/\s+\(#(\d+)\)$/) || []
    if (titleNumber && !prs.find((pr) => pr.number === +titleNumber)) {
      try {
        // it could also reference an issue so we do one extra check
        // to make sure it is really a pr that has been merged
        const realPr = JSON.parse(exec(`gh pr view ${titleNumber} --json url,number,state`, {
          stdio: 'pipe',
        }))
        if (realPr.state === 'MERGED') {
          prs.push(realPr)
        }
      } catch {
        // maybe an issue or something else went wrong
        // not super important so keep going
      }
    }

    for (const pr of prs) {
      title = title.replace(new RegExp(`\\s*\\(#${pr.number}\\)`, 'g'), '')
    }

    body = body
      .map((line) => line.trim()) // remove artificial line breaks
      .filter(Boolean) // remove blank lines
      .join('\n') // rejoin on new lines
      .split(/^[*-]/gm) // split on lines starting with bullets
      .map((line) => line.trim()) // remove spaces around bullets
      .filter((line) => !title.includes(line)) // rm lines that exist in the title
      // replace new lines for this bullet with spaces and re-bullet it
      .map((line) => `* ${line.trim().replace(/\n/gm, ' ')}`)
      .join('\n') // re-join with new lines

    commits[changelogType].push({
      hash: commit.abbreviatedOid,
      url: commit.url,
      title,
      type: changelogType,
      body,
      prs,
      credit: commit.authors.nodes.map((author) => {
        if (author.user && author.user.login) {
          return {
            name: `@${author.user.login}`,
            url: author.user.url,
          }
        }
        // if the commit used an email that's not associated with a github account
        // then the user field will be empty, so we fall back to using the committer's
        // name and email as specified by git
        return {
          name: author.name,
          url: `mailto:${author.email}`,
        }
      }),
    })
  }

  if (!Object.values(commits).flat().length) {
    const messages = allCommits.map((c) => c.message.trim().split('\n')[0])
    throw new Error(`No changelog commits found for "${range}":\n${messages.join('\n')}`)
  }

  // this doesnt work with majors but we dont do those very often
  const semverBump = commits.Features.length ? 'minor' : 'patch'
  const version = TAG_PREFIX + semver.parse(args.startTag).inc(semverBump).version
  const date = args.endTag ? exec(`git log -1 --date=short --format=%ad ${args.endTag}`) : today()

  const output = logger(RELEASE.title(version, date) + '\n')

  for (const key of Object.keys(commits)) {
    if (commits[key].length > 0) {
      output.group(`### ${key}\n`)

      for (const commit of commits[key]) {
        let groupCommit = `* [\`${commit.hash}\`](${commit.url})`
        for (const pr of commit.prs) {
          groupCommit += ` [#${pr.number}](${pr.url})`
        }
        groupCommit += ` ${commit.title}`
        if (key !== 'Dependencies') {
          for (const user of commit.credit) {
            if (args.type === 'gh') {
              groupCommit += ` (${user.name})`
            } else {
              groupCommit += ` ([${user.name}](${user.url}))`
            }
          }
        }

        output.group(groupCommit)
        // only optionally add full commit bodies to changelog
        if (commit.body && commit.body.length && args.format === 'long') {
          output.log(commit.body)
        }
        output.groupEnd()
      }

      output.log()
      output.groupEnd()
    }
  }

  return {
    date,
    version,
    release: output.toString(),
  }
}

const main = async (argv) => {
  const args = parseArgs(argv)

  if (args.read) {
    // this reads the release notes for that version
    let { release } = findRelease(args, args.tag)
    if (args.type === 'gh') {
      // changelog was written in markdown so convert user links to gh release style
      // XXX: this needs to be changed if the `generateRelease` format changes
      release = release.replace(/\(\[(@[a-z\d-]+)\]\(https:\/\/github.com\/[a-z\d-]+\)\)/gi, '($1)')
    }
    return console.log(release)
  }

  // otherwise fetch the requested release from github
  const { release, version, date } = await generateRelease(args)

  if (args.write) {
    // only try and run release manager issue update on write since that signals
    // the first time we know the version of the release
    try {
      exec(
        `node scripts/release-manager.js --update --version=${version.slice(1)} --date=${date}`, {
          stdio: 'pipe',
        })
    } catch (e) {
      console.error(`Updating release manager issue failed: ${e.stderr}`)
    }

    const { release: existing, changelog } = findRelease(args, version)
    fs.writeFileSync(
      args.file,
      existing
        // replace existing release with the newly generated one
        ? changelog.replace(existing, release)
        // otherwise prepend a new release at the start of the changelog
        : changelog.replace(RELEASE.h1, RELEASE.h1 + release + RELEASE.sep),
      'utf-8'
    )
    return console.log(
      `Release notes for ${version} written to "./${relative(process.cwd(), args.file)}".`
    )
  }

  console.log(release)
}

main(process.argv.slice(2))
