/**
 * Bump one release family's version and commit it, so the published version is
 * readable from the repository rather than derived inside CI
 * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
 *
 * The dsh family shares one version across its members and the workspace root:
 * `major`, `minor`, `patch`, or an explicit `x.y.z` (including a prerelease such
 * as `0.0.1-rc.1`). The vendored family has one version line per package, but
 * every release advances and publishes the complete family so the next release
 * never reuses an unchanged member's existing version from a different
 * repository state.
 *
 * The version lands in the manifests, the lockfile follows, and a human creates
 * the tag after the commit merges. CI never writes to the repository.
 */

import { readFileSync, writeFileSync } from 'node:fs'
import { join, matchesGlob } from 'node:path'
import { parseArgs } from 'node:util'
import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts'
import { capture, isEntry } from './process.ts'

/** Files npm publishes whether or not `files` lists them. */
const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const

/**
 * Inputs that decide what a built payload contains. A package whose `files`
 * selects `lib/` publishes build output that git does not track, so a change to
 * the sources or the build configuration changes the tarball while no published
 * path appears in the diff.
 */
const BUILD_INPUTS = ['src/**', 'tsconfig*.json', 'tsdown.config.*', 'build.config.*'] as const

/** Release types the dsh family accepts besides an explicit version. */
const RELEASE_TYPES = ['major', 'minor', 'patch'] as const

/** The workspace root manifest, which carries the dsh family's version. */
const ROOT_MANIFEST = 'package.json'

/** One manifest the bump rewrites, and the tag its new version will carry. */
interface PlannedVersion {
  /** Repository-relative manifest path. */
  readonly manifestPath: string
  /** Label for the log line. */
  readonly label: string
  /** The version the manifest currently carries. */
  readonly from: string
  /** The version to write. */
  readonly to: string
  /** The tag this version publishes from, or undefined for the workspace root. */
  readonly tag: string | undefined
}

/**
 * Split a version into its release numbers, discarding any prerelease segment.
 * @param version - the current version.
 * @returns Major, minor, and patch.
 */
function releaseNumbers(version: string): [number, number, number] {
  const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version)
  if (match === null) throw new Error(`cannot read release numbers from version ${version}`)
  return [Number(match[1]), Number(match[2]), Number(match[3])]
}

/**
 * Order two versions by their release numbers alone.
 * @param left - one version.
 * @param right - the other version.
 * @returns Negative when `left` is lower, positive when higher, zero when equal.
 */
function compareReleaseNumbers(left: string, right: string): number {
  const [leftMajor, leftMinor, leftPatch] = releaseNumbers(left)
  const [rightMajor, rightMinor, rightPatch] = releaseNumbers(right)
  return leftMajor - rightMajor || leftMinor - rightMinor || leftPatch - rightPatch
}

/**
 * The prerelease segment of a version, or undefined when it has none.
 * @param version - the version to read.
 * @returns The segment after the first `-`.
 */
function prereleaseOf(version: string): string | undefined {
  const index = version.indexOf('-')
  return index === -1 ? undefined : version.slice(index + 1)
}

/**
 * Order two versions by semver precedence.
 *
 * Git's version sort cannot stand in for this: `--sort=v:refname` places
 * `4.0.1-rc.1` above `4.0.1`, while semver gives a prerelease lower precedence
 * than the release it precedes. Prerelease identifiers compare field by field,
 * numeric fields numerically, so `rc.10` outranks `rc.1`.
 * @param left - one version.
 * @param right - the other version.
 * @returns Negative when `left` is lower, positive when higher, zero when equal.
 */
export function compareVersions(left: string, right: string): number {
  const numbers = compareReleaseNumbers(left, right)
  if (numbers !== 0) return numbers
  const leftPre = prereleaseOf(left)
  const rightPre = prereleaseOf(right)
  if (leftPre === undefined || rightPre === undefined) {
    if (leftPre === rightPre) return 0
    return leftPre === undefined ? 1 : -1
  }
  const leftFields = leftPre.split('.')
  const rightFields = rightPre.split('.')
  for (let index = 0; index < Math.max(leftFields.length, rightFields.length); index += 1) {
    const leftField = leftFields[index]
    const rightField = rightFields[index]
    // A shorter identifier list has lower precedence when all its fields match.
    if (leftField === undefined) return -1
    if (rightField === undefined) return 1
    if (leftField === rightField) continue
    const leftNumeric = /^\d+$/.test(leftField)
    const rightNumeric = /^\d+$/.test(rightField)
    if (leftNumeric && rightNumeric) return Number(leftField) - Number(rightField)
    // Numeric fields have lower precedence than alphanumeric ones.
    if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
    return leftField < rightField ? -1 : 1
  }
  return 0
}

/**
 * The next dsh version.
 * @param current - the family's current shared version.
 * @param request - `major`, `minor`, `patch`, or an explicit version.
 * @returns The target version.
 */
function nextSharedVersion(current: string, request: string): string {
  if (!RELEASE_TYPES.includes(request as typeof RELEASE_TYPES[number])) {
    if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(request)) {
      throw new Error(`usage: release:dsh <major|minor|patch|x.y.z>, got ${request}`)
    }
    return request
  }
  const [major, minor, patch] = releaseNumbers(current)
  if (request === 'major') return `${String(major + 1)}.0.0`
  if (request === 'minor') return `${String(major)}.${String(minor + 1)}.0`
  return `${String(major)}.${String(minor)}.${String(patch + 1)}`
}

/**
 * The version a vendored package publishes next.
 *
 * The baseline is the higher of the manifest version and the last tagged
 * version: a vendor re-sync restores upstream's version, which is lower than
 * the release version this repository already reserved, and incrementing that
 * would reuse an existing version.
 *
 * A prerelease does not consume its own release numbers. Publishing
 * `4.0.1-rc.1` leaves `4.0.1` free, so the next stable version is `4.0.1`
 * rather than `4.0.2`, and a second prerelease keeps those numbers too.
 * @param current - the package's manifest version.
 * @param tagged - the version its newest tag names, when it has one.
 * @param prerelease - prerelease identifier to append, for a rehearsal publication.
 * @returns The target version.
 */
export function nextVendorVersion(
  current: string,
  tagged: string | undefined,
  prerelease?: string,
): string {
  const taggedOrder = tagged === undefined ? undefined : compareReleaseNumbers(tagged, current)
  const ahead = taggedOrder !== undefined && taggedOrder > 0
  const baseline = ahead && tagged !== undefined ? tagged : current
  const [major, minor, patch] = releaseNumbers(baseline)
  // Reuse the numbers when the tagged version that set them is a prerelease
  // of them; increment when a stable release already holds them.
  const taggedPrerelease = tagged !== undefined && prereleaseOf(tagged) !== undefined
  const sameReleasePrereleases = taggedOrder === 0 && prereleaseOf(current) !== undefined
  const reuse = taggedPrerelease && (ahead || sameReleasePrereleases)
  const numbers = reuse
    ? `${String(major)}.${String(minor)}.${String(patch)}`
    : `${String(major)}.${String(minor)}.${String(patch + 1)}`
  return prerelease === undefined ? numbers : `${numbers}-${prerelease}`
}

/**
 * Whether a repository-relative path reaches the member's published payload.
 * @param member - the member the path belongs to.
 * @param path - repository-relative path.
 * @returns True when `files`, npm's always-published set, or a build input selects it.
 */
export function reachesPayload(member: ReleaseMember, path: string): boolean {
  const relative = path.slice(member.directory.length + 1)
  const files = member.manifest.files
  const selected = Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : []
  const built = selected.some(pattern => pattern.startsWith('lib'))
  const patterns = [...ALWAYS_PUBLISHED, ...selected, ...built ? BUILD_INPUTS : []]
  return patterns.some(pattern =>
    matchesGlob(relative, pattern) || matchesGlob(relative, `${pattern}/**`) || relative === pattern)
}

/**
 * The newest version a member tagged.
 * @param family - the member's family.
 * @param member - the member.
 * @returns The version, or undefined when the member has no release tag.
 */
function lastTaggedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined {
  const prefix = family.tagPrefixFor(member)
  const versions = capture('git', ['tag', '--list', `${prefix}*`])
    .split('\n').filter(line => line !== '').map(tag => tag.slice(prefix.length))
  if (versions.length === 0) return undefined
  return versions.reduce((newest, candidate) => compareVersions(candidate, newest) > 0 ? candidate : newest)
}

/**
 * Write a version into a manifest, preserving formatting and key order.
 * @param root - repository root.
 * @param manifestPath - repository-relative manifest path.
 * @param from - the version the manifest currently carries.
 * @param to - the target version.
 */
function writeVersion(root: string, manifestPath: string, from: string, to: string): void {
  const path = join(root, manifestPath)
  const text = readFileSync(path, 'utf8')
  const line = `"version": "${from}"`
  if (!text.includes(line)) throw new Error(`${manifestPath}: cannot locate ${line}`)
  writeFileSync(path, text.replace(line, `"version": "${to}"`))
}

/**
 * Read the workspace root version.
 * @param root - repository root.
 * @returns The root manifest version.
 */
function rootVersion(root: string): string {
  const manifest: unknown = JSON.parse(readFileSync(join(root, ROOT_MANIFEST), 'utf8'))
  const version = (manifest as Record<string, unknown>).version
  if (typeof version !== 'string') throw new Error('package.json must declare a string version')
  return version
}

/**
 * Plan the dsh family's rewrite: one version for every member and the root.
 * @param family - the dsh family.
 * @param root - repository root.
 * @param members - the family's members.
 * @param request - `major`, `minor`, `patch`, or an explicit version.
 * @returns The manifests to rewrite and the shared target version.
 */
function planShared(
  family: ReleaseFamily,
  root: string,
  members: readonly ReleaseMember[],
  request: string,
): { planned: PlannedVersion[]; version: string } {
  const [first] = members
  if (first === undefined) throw new Error(`release family ${family.id} has no members`)
  const version = nextSharedVersion(first.version, request)
  // The workspace root carries the family version too: the workspace constraint
  // requires every member's version to equal the root's.
  const planned: PlannedVersion[] = [
    { manifestPath: ROOT_MANIFEST, label: ROOT_MANIFEST, from: rootVersion(root), to: version, tag: undefined },
  ]
  for (const member of members) {
    planned.push({
      manifestPath: join(member.directory, 'package.json'),
      label: member.directory,
      from: member.version,
      to: version,
      tag: family.tagFor({ ...member, version }),
    })
  }
  return { planned, version }
}

/**
 * Plan the vendored family's rewrite: every package advances together while
 * retaining its own version line and tag.
 * @param family - the vendored family.
 * @param members - the family's members.
 * @param prerelease - prerelease identifier to append, for a rehearsal publication.
 * @returns The manifests to rewrite.
 */
function planPerPackage(
  family: ReleaseFamily,
  members: readonly ReleaseMember[],
  prerelease: string | undefined,
): PlannedVersion[] {
  const planned: PlannedVersion[] = []
  for (const member of members) {
    const tagged = lastTaggedVersion(family, member)
    const to = nextVendorVersion(member.version, tagged, prerelease)
    planned.push({
      manifestPath: join(member.directory, 'package.json'),
      label: member.directory,
      from: member.version,
      to,
      tag: family.tagFor({ ...member, version: to }),
    })
  }
  return planned
}

/**
 * Bump the family named by `--family` and commit; `--dry-run` only reports the
 * plan. `--prerelease rc.1` makes the vendored family publish a rehearsal
 * version, which never takes the stable dist-tag.
 */
function main(): void {
  const { values, positionals } = parseArgs({
    options: {
      family: { type: 'string' },
      prerelease: { type: 'string' },
      'dry-run': { type: 'boolean', default: false },
    },
    allowPositionals: true,
  })
  if (values.family === undefined) throw new Error('usage: bump.ts --family <dsh|vendor> [version]')

  const family = releaseFamily(values.family)
  const root = process.cwd()
  const members = family.members(root)
  family.verifyVersions(members)

  let planned: PlannedVersion[]
  let sharedVersion: string | undefined
  if (family.id === 'dsh') {
    const request = positionals[0]
    if (request === undefined) throw new Error('usage: release:dsh <major|minor|patch|x.y.z>')
    if (values.prerelease !== undefined) {
      throw new Error('release:dsh takes the prerelease in its version argument, as in 0.0.1-rc.1')
    }
    const shared = planShared(family, root, members, request)
    planned = shared.planned
    sharedVersion = shared.version
  } else {
    if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch')
    if (values.prerelease !== undefined && !/^[0-9A-Za-z.-]+$/.test(values.prerelease)) {
      throw new Error(`--prerelease must be a semver prerelease identifier, got ${values.prerelease}`)
    }
    planned = planPerPackage(family, members, values.prerelease)
  }

  if (planned.length === 0) {
    console.log(`release bump: family ${family.id}, nothing changed since publication`)
    return
  }

  const dryRun = values['dry-run']
  if (!dryRun) {
    for (const entry of planned) writeVersion(root, entry.manifestPath, entry.from, entry.to)
    capture('pnpm', ['install', '--lockfile-only'])
  }

  const summary = sharedVersion
    ?? planned.map(entry => `${entry.label.replace('vendor/', '')} ${entry.to}`).join(', ')
  console.log(`release bump: family ${family.id} -> ${summary}`)
  for (const entry of planned) console.log(`  ${entry.label}: ${entry.from} -> ${entry.to}`)

  if (dryRun) {
    console.log('release bump: dry run, nothing written')
    return
  }
  capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => entry.manifestPath)])
  capture('git', ['commit', '-m', `release(${family.id}): ${summary}`])
  console.log('release bump: committed. After this merges to master, tag it:')
  for (const tag of [...new Set(planned.map(entry => entry.tag).filter(tag => tag !== undefined))]) {
    console.log(`  git tag ${tag} <merge commit> && git push origin ${tag}`)
  }
}

if (isEntry(import.meta.url)) main()
