const npa = require('npm-package-arg')
const { log } = require('proc-log')
const {
  getTrustedRegistryIdentity,
  resolvedSourceSpecs,
} = require('@npmcli/arborist/lib/script-allowed.js')

// Pure helpers that implement the RFC's pin-mismatch table for
// `npm approve-scripts` and `npm deny-scripts`.
//
// Approving writes either `"<spec>": true` or `"<name>": true` to the
// project's `allowScripts` field, depending on `--allow-scripts-pin` and the currently
// installed versions.
//
// Denying always writes `"<name>": false`, regardless of `--allow-scripts-pin`, per the
// RFC's asymmetric-pin rule.

const primaryResolvedSource = (node) => resolvedSourceSpecs(node)[0] || ''

// Convert an arborist Node into the spec string used for a versioned policy
// entry. Returns `null` if the node cannot be represented as a versioned key
// derived from trusted sources (lockfile URL for registry, hosted shortcut
// for git, the resolved file path for local installs). Never falls back to
// `node.packageName` / `node.version`, which are tarball-controlled.
const versionedKeyFor = (node) => {
  if (!node) {
    return null
  }
  const resolved = primaryResolvedSource(node)
  if (resolved.startsWith('git')) {
    try {
      const parsed = npa(resolved)
      if (parsed.hosted) {
        const committish = parsed.gitCommittish || parsed.hosted.committish
        const base = parsed.hosted.shortcut({ noCommittish: true })
        return committish ? `${base}#${committish}` : base
      }
    } catch {
      /* istanbul ignore next: npa already parsed this string in keyTargetsNode */
      return null
    }
    return null
  }
  if (/^https?:\/\//.test(resolved)) {
    const trusted = getTrustedRegistryIdentity(node)
    if (trusted && trusted.version) {
      return `${trusted.name}@${trusted.version}`
    }
    // Registry node with a resolved URL that versionFromTgz couldn't
    // parse (private-registry mirror, alternate CDN URL shape). Leave a
    // breadcrumb so users notice when policy keys are silently pruned.
    log.silly(
      'allow-scripts',
      `unable to derive trusted versioned key for ${node.path || node.name || '<unknown>'} ` +
      `(resolved: ${resolved}); key will be pruned on next save`
    )
    return null
  }
  /* istanbul ignore next: 'file:' and '/' branches are each covered separately */
  if (resolved.startsWith('file:') || resolved.startsWith('/')) {
    return resolved
  }
  // No trusted source. Refuse to compose a key from attacker-controlled
  // `node.packageName` / `node.version`.
  /* istanbul ignore next: callers filter out non-registry/non-file nodes before reaching this fallback */
  return null
}

// Convert an arborist Node into the spec string used for a name-only policy
// entry. Same trust rules as versionedKeyFor — returns `null` rather than
// falling back to tarball-controlled fields.
const nameKeyFor = (node) => {
  if (!node) {
    return null
  }
  const resolved = primaryResolvedSource(node)
  if (resolved.startsWith('git')) {
    try {
      const parsed = npa(resolved)
      if (parsed.hosted) {
        return parsed.hosted.shortcut({ noCommittish: true })
      }
    } catch {
      /* istanbul ignore next: npa already parsed this string in keyTargetsNode */
      return null
    }
    return null
  }
  if (resolved.startsWith('file:') || resolved.startsWith('/')) {
    return resolved
  }
  // Registry deps: only the URL-derived (or edges-derived, in the
  // omit-lockfile case) trusted name is acceptable.
  const trusted = getTrustedRegistryIdentity(node)
  return trusted ? trusted.name : null
}

const isSingleVersionPin = (key) => {
  try {
    const parsed = npa(key)
    return parsed.type === 'version'
  } catch {
    return false
  }
}

// Build the warning string emitted when an existing deny entry blocks
// an approval. Per RFC, a name-only deny ("pkg": false) is widest and
// the only remediation is to remove the entry. A versioned deny
// ("pkg@1.2.3": false or a disjunction) blocks only specific versions;
// the user can either widen it via `npm install-scripts deny <name>` or
// remove it to approve the currently-installed version only.
const denyWarning = (key, subject, name) => {
  if (isNameOnlyKey(key)) {
    return `${key} is denied; remove the entry from allowScripts to approve ${subject}.`
  }
  /* istanbul ignore next: name fallback is defensive; callers pass nameKeyFor(sample) */
  const widenTarget = name || 'this package'
  return `${key} is a versioned deny; run \`npm install-scripts deny ${widenTarget}\` ` +
    `to widen the deny to all versions of ${widenTarget}, or remove the entry ` +
    `to approve ${subject}.`
}

const isNameOnlyKey = (key) => {
  try {
    const parsed = npa(key)
    if (parsed.type === 'tag') {
      return true
    }
    if (parsed.type === 'range') {
      return parsed.fetchSpec === '*'
        || parsed.rawSpec === ''
        || parsed.rawSpec === '*'
    }
    return false
  } catch {
    /* istanbul ignore next: keys reaching this helper have already parsed via keyTargetsNode */
    return false
  }
}

// Does this policy key target this node by identity (ignoring the
// allow/deny value)?
//
// Registry keys (`tag`, `range`, `version`) require a trusted identity on
// the node. If the node has no `getTrustedRegistryIdentity` result, the
// key does not match — never fall back to `node.name`, which is the
// install-directory name and is forgeable through aliases / manifest
// confusion.
const keyTargetsNode = (key, node) => {
  let parsed
  try {
    parsed = npa(key)
  } catch {
    return false
  }
  switch (parsed.type) {
    case 'tag':
    case 'range':
    case 'version': {
      const trusted = getTrustedRegistryIdentity(node)
      if (!trusted) {
        return false
      }
      return trusted.name === parsed.name
    }
    case 'git': {
      let resolvedParsed
      try {
        const resolved = primaryResolvedSource(node)
        resolvedParsed = resolved ? npa(resolved) : null
      } catch {
        /* istanbul ignore next */
        return false
      }
      const keyHost = parsed.hosted?.ssh({ noCommittish: true })
      const nodeHost = resolvedParsed?.hosted?.ssh({ noCommittish: true })
      return !!(keyHost && nodeHost && keyHost === nodeHost)
    }
    case 'file':
    case 'directory':
    case 'remote':
      return resolvedSourceSpecs(node)
        .some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec)
    default:
      return false
  }
}

// Apply approvals for all currently-installed versions of a single package.
//
// `nodes` must all share an identity (same package name for registry deps,
// or same hosted shortcut for git deps, etc.). The caller is responsible
// for grouping nodes correctly.
//
// Returns `{ allowScripts, changes, warning }` where:
//   - `allowScripts` is the new object (the input is never mutated)
//   - `changes` is a list of `{ key, change }` entries describing edits
//   - `warning` is an optional message to surface to the user
const applyApprovalForPackage = (existing, nodes, { pin = true } = {}) => {
  const allowScripts = { ...existing }
  const changes = []

  if (!Array.isArray(nodes) || nodes.length === 0) {
    return { allowScripts, changes }
  }

  const sample = nodes[0]
  const name = nameKeyFor(sample)

  // Deny-wins: any existing false that targets any installed version aborts.
  for (const node of nodes) {
    for (const [key, value] of Object.entries(allowScripts)) {
      if (value === false && keyTargetsNode(key, node)) {
        /* istanbul ignore next: name fallback covers the empty-name edge case */
        const subject = name || 'this package'
        return {
          allowScripts,
          changes,
          warning: denyWarning(key, subject, name),
        }
      }
    }
  }

  if (!pin) {
    // Name-only mode: collapse any single-version pins for this package
    // into a single name-only entry.
    for (const key of Object.keys(allowScripts)) {
      if (
        keyTargetsNode(key, sample) &&
        key !== name &&
        isSingleVersionPin(key) &&
        allowScripts[key] === true
      ) {
        delete allowScripts[key]
      }
    }

    /* istanbul ignore else: name === null is the no-identity path tested separately */
    if (name && allowScripts[name] !== true) {
      allowScripts[name] = true
      changes.push({ key: name, change: 'added' })
    }
    return { allowScripts, changes }
  }

  // Pin mode. For each currently installed version, write a single-version
  // pin if one is not already in place. Stale single-version pins for this
  // package are removed. Per the RFC's pin-mismatch table, an existing
  // name-only entry (`pkg: true`) is replaced by `pkg@x.y.z: true` once
  // every installed version has a pin.
  const versionedKeys = nodes.map(versionedKeyFor)
  const installedKeys = new Set(versionedKeys.filter(Boolean))

  // A registry dep with no `resolved` URL in the lockfile has no trustable
  // version (getTrustedRegistryIdentity won't trust the tarball's
  // node.version), so versionedKeyFor returns null and a `pkg@x.y.z` pin can
  // never match it (npm/cli#9558). When any installed version can't be
  // pinned, approve the whole package by name and drop now-redundant pins.
  if (name && versionedKeys.some(key => !key)) {
    for (const key of Object.keys(allowScripts)) {
      if (
        keyTargetsNode(key, sample) &&
        key !== name &&
        isSingleVersionPin(key) &&
        allowScripts[key] === true
      ) {
        delete allowScripts[key]
        changes.push({ key, change: 'removed-pinned-allow' })
      }
    }
    if (allowScripts[name] !== true) {
      allowScripts[name] = true
      changes.push({ key: name, change: 'added' })
    }
    return {
      allowScripts,
      changes,
      warning: changes.length
        ? `${name}: approved by name (all versions) because its ` +
        `package-lock.json entry has no "resolved" URL, so npm can't pin a ` +
        `specific version. Run \`npm install\` to refresh the lockfile and ` +
        `enable pinning.`
        : undefined,
    }
  }

  for (const key of Object.keys(allowScripts)) {
    if (
      keyTargetsNode(key, sample) &&
      isSingleVersionPin(key) &&
      allowScripts[key] === true &&
      !installedKeys.has(key)
    ) {
      delete allowScripts[key]
      changes.push({ key, change: 'removed-stale' })
    }
  }

  for (const key of installedKeys) {
    if (allowScripts[key] !== true) {
      allowScripts[key] = true
      changes.push({ key, change: 'added' })
    }
  }

  // Upgrade: drop the name-only entry once every installed version has a
  // pin. The operation is convergent: running the command twice produces
  // the same shape regardless of the starting state.
  if (
    installedKeys.size > 0 &&
    name &&
    !installedKeys.has(name) &&
    allowScripts[name] === true
  ) {
    delete allowScripts[name]
    changes.push({ key: name, change: 'replaced-by-pin' })
  }

  return { allowScripts, changes }
}

// Apply a deny for a single package. Always name-only; ignores `--allow-scripts-pin`.
const applyDenyForPackage = (existing, nodes) => {
  const allowScripts = { ...existing }
  const changes = []

  if (!Array.isArray(nodes) || nodes.length === 0) {
    return { allowScripts, changes }
  }

  const sample = nodes[0]
  const name = nameKeyFor(sample)
  if (!name) {
    return { allowScripts, changes }
  }

  // Drop any pinned allow entries for this package: the name-only deny
  // overrides them anyway, and leaving them in place is confusing.
  for (const key of Object.keys(allowScripts)) {
    if (keyTargetsNode(key, sample) && key !== name) {
      delete allowScripts[key]
      changes.push({ key, change: 'removed-pinned-allow' })
    }
  }

  if (allowScripts[name] !== false) {
    allowScripts[name] = false
    changes.push({ key: name, change: 'added' })
  }
  return { allowScripts, changes }
}

module.exports = {
  applyApprovalForPackage,
  applyDenyForPackage,
  versionedKeyFor,
  nameKeyFor,
  keyTargetsNode,
  isSingleVersionPin,
}
