const { readFile, writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')
const parseJSON = require('json-parse-even-better-errors')

const updateDeps = require('./update-dependencies.js')
const updateScripts = require('./update-scripts.js')
const updateWorkspaces = require('./update-workspaces.js')
const { normalize, syncNormalize } = require('./normalize.js')
const { read, parse } = require('./read-package.js')
const { packageSort } = require('./sort.js')

// a list of handy specialized helper functions that take
// care of special cases that are handled by the npm cli
const knownSteps = new Set([
  updateDeps,
  updateScripts,
  updateWorkspaces,
])

// list of all keys that are handled by "knownSteps" helpers
const knownKeys = new Set([
  ...updateDeps.knownKeys,
  'scripts',
  'workspaces',
])

class PackageJson {
  // npm pkg fix
  static fixSteps = Object.freeze([
    'binRefs',
    'bundleDependencies',
    'fixName',
    'fixVersionField',
    'fixRepositoryField',
    'fixDependencies',
    'devDependencies',
    'scriptpath',
  ])

  static normalizeSteps = Object.freeze([
    '_id',
    '_attributes',
    'bundledDependencies',
    'bundleDependencies',
    'overrides',
    'optionalDedupe',
    'scripts',
    'funding',
    'bin',
    'binDir',
  ])

  static prepareSteps = Object.freeze([
    '_id',
    '_attributes',
    'bundledDependencies',
    'bundleDependencies',
    'bundleDependenciesDeleteFalse',
    'overrides',
    'gypfile',
    'scriptpath',
    'authors',
    'readme',
    'mans',
    'binDir',
    'gitHead',
    'fillTypes',
    'normalizeData',
    'binRefs',
  ])

  // create a new empty package.json, so we can save at the given path even
  // though we didn't start from a parsed file
  static async create (path, opts = {}) {
    const p = new PackageJson()
    await p.create(path)
    if (opts.data) {
      return p.update(opts.data)
    }
    return p
  }

  // Loads a package.json at given path and JSON parses
  static async load (path, opts = {}) {
    const p = new PackageJson()
    // Avoid try/catch if we aren't going to create
    if (!opts.create) {
      return p.load(path)
    }

    try {
      return await p.load(path)
    } catch (err) {
      if (!err.message.startsWith('Could not read package.json')) {
        throw err
      }
      return await p.create(path)
    }
  }

  // npm pkg fix
  static async fix (path, opts) {
    const p = new PackageJson()
    await p.load(path, true)
    return p.fix(opts)
  }

  // read-package-json compatible behavior
  static async prepare (path, opts) {
    const p = new PackageJson()
    await p.load(path, true)
    return p.prepare(opts)
  }

  // read-package-json-fast compatible behavior
  static async normalize (path, opts) {
    const p = new PackageJson()
    await p.load(path)
    return p.normalize(opts)
  }

  #path
  #manifest
  #readFileContent = ''
  #canSave = true

  // Load content from given path
  async load (path, parseIndex) {
    this.#path = path
    let parseErr
    try {
      this.#readFileContent = await read(this.filename)
    } catch (err) {
      if (!parseIndex) {
        throw err
      }
      parseErr = err
    }

    if (parseErr) {
      const indexFile = resolve(this.path, 'index.js')
      let indexFileContent
      try {
        indexFileContent = await readFile(indexFile, 'utf8')
      } catch (err) {
        throw parseErr
      }
      try {
        this.fromComment(indexFileContent)
      } catch (err) {
        throw parseErr
      }
      // This wasn't a package.json so prevent saving
      this.#canSave = false
      return this
    }

    return this.fromJSON(this.#readFileContent)
  }

  // Load data from a JSON string/buffer
  fromJSON (data) {
    this.#manifest = parse(data)
    return this
  }

  // Manually set data from an existing object
  fromContent (data) {
    if (!data || typeof data !== 'object') {
      throw new Error('Content data must be an object')
    }
    this.#manifest = data
    this.#canSave = false
    return this
  }

  // Load data from a comment
  // /**package { "name": "foo", "version": "1.2.3", ... } **/
  fromComment (data) {
    data = data.split(/^\/\*\*package(?:\s|$)/m)

    if (data.length < 2) {
      throw new Error('File has no package in comments')
    }
    data = data[1]
    data = data.split(/\*\*\/$/m)

    if (data.length < 2) {
      throw new Error('File has no package in comments')
    }
    data = data[0]
    data = data.replace(/^\s*\*/mg, '')

    this.#manifest = parseJSON(data)
    return this
  }

  get content () {
    return this.#manifest
  }

  get path () {
    return this.#path
  }

  get filename () {
    if (this.path) {
      return resolve(this.path, 'package.json')
    }
    return undefined
  }

  create (path) {
    this.#path = path
    this.#manifest = {}
    return this
  }

  // This should be the ONLY way to set content in the manifest
  update (content) {
    if (!this.content) {
      throw new Error('Can not update without content.  Please `load` or `create`')
    }

    for (const step of knownSteps) {
      this.#manifest = step({ content, originalContent: this.content })
    }

    // unknown properties will just be overwritten
    for (const [key, value] of Object.entries(content)) {
      if (!knownKeys.has(key)) {
        this.content[key] = value
      }
    }

    return this
  }

  async save ({ sort } = {}) {
    if (!this.#canSave) {
      throw new Error('No package.json to save to')
    }
    const {
      [Symbol.for('indent')]: indent,
      [Symbol.for('newline')]: newline,
      ...rest
    } = this.content

    const format = indent === undefined ? '  ' : indent
    const eol = newline === undefined ? '\n' : newline

    const content = sort ? packageSort(rest) : rest

    const fileContent = `${
      JSON.stringify(content, null, format)
    }\n`
      .replace(/\n/g, eol)

    if (fileContent.trim() !== this.#readFileContent.trim()) {
      const written = await writeFile(this.filename, fileContent)
      this.#readFileContent = fileContent
      return written
    }
  }

  // steps is NOT overrideable here because this is a legacy function that's not being used in new places
  syncNormalize (opts = {}) {
    opts.steps = this.constructor.normalizeSteps.filter(s => s !== '_attributes')
    syncNormalize(this, opts)
    return this
  }

  async normalize (opts = {}) {
    if (!opts.steps) {
      opts.steps = this.constructor.normalizeSteps
    }
    await normalize(this, opts)
    return this
  }

  async prepare (opts = {}) {
    if (!opts.steps) {
      opts.steps = this.constructor.prepareSteps
    }
    await normalize(this, opts)
    return this
  }

  async fix (opts = {}) {
    // This one is not overridable
    opts.steps = this.constructor.fixSteps
    await normalize(this, opts)
    return this
  }
}

module.exports = PackageJson
