import { execFileSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'

export const RELEASES_TS_PATH = 'web/src/data/releases.ts'
export const CHANGELOG_PATH = 'CHANGELOG.md'
export const MANIFEST_PATH = '.release-please-manifest.json'
export const GENERATED_ENTRY_MARKER =
  '  // Generated by release automation; do not edit this entry by hand.'

/** Prior wording kept only so pending bot PRs with the old marker still refresh. */
const LEGACY_GENERATED_ENTRY_MARKER =
  '  // Generated by release automation; remove this comment before hand-curating.'

const GENERATED_ENTRY_MARKERS = [GENERATED_ENTRY_MARKER, LEGACY_GENERATED_ENTRY_MARKER] as const

const SEMVER_IDENTIFIER = '(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)'
const SEMVER_PATTERN = new RegExp(
  `^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)`
  + `(?:-(${SEMVER_IDENTIFIER}(?:\\.${SEMVER_IDENTIFIER})*))?`
  + `(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`,
)

export type ReleaseEntry = {
  version: string
  date: string
  theme: string
  highlights: string[]
}

type ParsedSemVer = {
  core: [bigint, bigint, bigint]
  prerelease: string[] | null
}

function parseSemVer(version: string): ParsedSemVer {
  const match = version.match(SEMVER_PATTERN)
  if (!match) throw new Error(`invalid SemVer: ${version}`)
  return {
    core: [BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!)],
    prerelease: match[4]?.split('.') ?? null,
  }
}

export function compareSemVer(leftVersion: string, rightVersion: string): number {
  const left = parseSemVer(leftVersion)
  const right = parseSemVer(rightVersion)
  for (let index = 0; index < left.core.length; index++) {
    if (left.core[index] !== right.core[index])
      return left.core[index]! < right.core[index]! ? -1 : 1
  }
  if (left.prerelease === null || right.prerelease === null)
    return left.prerelease === right.prerelease ? 0 : left.prerelease === null ? 1 : -1

  const length = Math.max(left.prerelease.length, right.prerelease.length)
  for (let index = 0; index < length; index++) {
    const a = left.prerelease[index]
    const b = right.prerelease[index]
    if (a === undefined || b === undefined)
      return a === b ? 0 : a === undefined ? -1 : 1
    if (a === b) continue
    const aNumeric = /^\d+$/.test(a)
    const bNumeric = /^\d+$/.test(b)
    if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1
    if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
    return a < b ? -1 : 1
  }
  return 0
}

export function assertVersionAdvances(baseVersion: string, nextVersion: string): void {
  if (compareSemVer(nextVersion, baseVersion) <= 0)
    throw new Error(`release version must advance: ${baseVersion} -> ${nextVersion}`)
}

export function sanitizeChangelogBullet(line: string): string {
  let sanitized = line
    .replace(/^\*\s+/, '')
    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
    .replace(/`([^`]+)`/g, '$1')
    .replace(/\*\*([^*]+)\*\*/g, '$1')
    .replace(/\*([^*]+)\*/g, '$1')
    .trim()

  while (true) {
    const next = sanitized.replace(/\s*\((?:#\d+|[0-9a-f]{7,40})\)\s*$/i, '').trim()
    if (next === sanitized) break
    sanitized = next
  }

  return decodeHtmlEntities(sanitized.replace(/,\s*closes\s+#\d+$/i, '').trim())
}

function decodeHtmlEntities(value: string): string {
  const named: Record<string, string> = {
    amp: '&',
    apos: "'",
    gt: '>',
    lt: '<',
    quot: '"',
  }

  return value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, (entity, decimal, hexadecimal, name) => {
    if (name) return named[name.toLowerCase()] ?? entity
    const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
    if (!Number.isSafeInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff)
      return entity
    if (codePoint >= 0xd800 && codePoint <= 0xdfff) return entity
    return String.fromCodePoint(codePoint)
  })
}

export function parseChangelogSection(
  changelog: string,
  version?: string,
): { version: string; date: string; highlights: string[] } | null {
  const target = version ?? readManifestVersion()
  const sectionPattern = new RegExp(
    `^## \\[${escapeRegExp(target)}\\][^\\n]*\\((\\d{4}-\\d{2}-\\d{2})\\)\\s*$`,
    'm',
  )
  const headerMatch = changelog.match(sectionPattern)
  if (!headerMatch) return null

  const start = headerMatch.index ?? changelog.indexOf(headerMatch[0])
  const afterHeader = changelog.slice(start + headerMatch[0].length)
  const nextSection = afterHeader.search(/^## \[/m)
  const sectionBody = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection)
  const highlights = sectionBody
    .split('\n')
    .map(line => line.trim())
    .filter(line => line.startsWith('* '))
    .map(sanitizeChangelogBullet)
    .filter(Boolean)
    .slice(0, 5)

  return { version: target, date: headerMatch[1]!, highlights }
}

export function deriveTheme(highlights: string[]): string {
  if (highlights.length === 0) return 'release highlights'
  const first = highlights[0]!
  const scopeMatch = first.match(/^([a-z0-9-]+):\s*/i)
  const theme = scopeMatch ? first.slice(scopeMatch[0].length) : first
  const characters = [...theme]
  return characters.length > 72 ? `${characters.slice(0, 69).join('')}…` : theme
}

export function readManifestVersion(manifest = readFileSync(MANIFEST_PATH, 'utf8')): string {
  const parsed = JSON.parse(manifest) as Record<string, string>
  const version = parsed['.']
  if (!version) throw new Error(`missing root version in ${MANIFEST_PATH}`)
  return version
}

export function readCurrentTopVersion(releasesTs: string): string | null {
  const match = releasesTs.match(
    /export const releases: Release\[\] = \[\s*(?:\/\/[^\n]*\s*)?\{\s*version: ["']([^"']+)["']/s,
  )
  return match?.[1] ?? null
}

export function formatReleaseEntry(entry: ReleaseEntry, indent = '  '): string {
  const quote = (value: string) => JSON.stringify(value)
  const highlightLines = entry.highlights.map(highlight => `${indent}    ${quote(highlight)},`).join('\n')
  return `${indent}{
${indent}  version: ${quote(entry.version)},
${indent}  date: ${quote(entry.date)},
${indent}  theme: ${quote(entry.theme)},
${indent}  highlights: [
${highlightLines}
${indent}  ],
${indent}},
`
}

export function insertReleaseEntry(releasesTs: string, entry: ReleaseEntry): string {
  const marker = 'export const releases: Release[] = ['
  const index = releasesTs.indexOf(marker)
  if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`)

  const insertAt = index + marker.length
  const eol = detectLineEnding(releasesTs)
  const formattedEntry = formatReleaseEntry(entry).replaceAll('\n', eol)
  // A merged release can leave the automation marker on the published top
  // entry. Strip that leftover marker so only the new draft owns it.
  const rest = stripLeadingGeneratedMarker(releasesTs.slice(insertAt), eol)
  return `${releasesTs.slice(0, insertAt)}${eol}${GENERATED_ENTRY_MARKER}${eol}${formattedEntry}${rest}`
}

export function replaceTopReleaseEntry(releasesTs: string, entry: ReleaseEntry): string {
  const marker = 'export const releases: Release[] = ['
  const index = releasesTs.indexOf(marker)
  if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`)
  const insertAt = index + marker.length
  const existing = stripLeadingGeneratedMarker(releasesTs.slice(insertAt), detectLineEnding(releasesTs))
  const endMatch = /^  \},\r?\n/m.exec(existing)
  if (!endMatch) throw new Error(`could not find top release entry in ${RELEASES_TS_PATH}`)
  const eol = detectLineEnding(releasesTs)
  const formattedEntry = formatReleaseEntry(entry).replaceAll('\n', eol)
  const afterEntry = endMatch.index + endMatch[0].length
  return `${releasesTs.slice(0, insertAt)}${eol}${GENERATED_ENTRY_MARKER}${eol}${formattedEntry}${existing.slice(afterEntry)}`
}

export function hasGeneratedTopEntry(releasesTs: string): boolean {
  const marker = 'export const releases: Release[] = ['
  const index = releasesTs.indexOf(marker)
  if (index === -1) return false
  const existing = releasesTs.slice(index + marker.length)
  return GENERATED_ENTRY_MARKERS.some(
    generated =>
      existing.startsWith(`\n${generated}\n`)
      || existing.startsWith(`\r\n${generated}\r\n`),
  )
}

function detectLineEnding(value: string): '\n' | '\r\n' {
  return value.includes('\r\n') ? '\r\n' : '\n'
}

function stripLeadingGeneratedMarker(value: string, eol: '\n' | '\r\n'): string {
  for (const generated of GENERATED_ENTRY_MARKERS) {
    const prefix = `${eol}${generated}${eol}`
    if (value.startsWith(prefix)) return value.slice(prefix.length)
  }
  return value
}

export function readBaseReleasesTs(baseRef: string): string {
  return execFileSync('git', ['show', `${baseRef}:${RELEASES_TS_PATH}`], { encoding: 'utf8' })
}

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

export type SyncResult =
  | { status: 'unchanged'; version: string; reason: string }
  | { status: 'updated'; version: string; content: string }

export function syncWebReleaseEntry(options: {
  changelog?: string
  releasesTs?: string
  baseReleasesTs?: string
  baseRef?: string
  manifestVersion?: string
} = {}): SyncResult {
  const version = options.manifestVersion ?? readManifestVersion()
  const releasesTs = options.releasesTs ?? readFileSync(RELEASES_TS_PATH, 'utf8')
  const currentTop = readCurrentTopVersion(releasesTs)
  const baseReleasesTs = options.baseReleasesTs
    ?? (options.baseRef ? readBaseReleasesTs(options.baseRef) : null)
  if (baseReleasesTs === null)
    throw new Error('missing base release ref; pass --base-ref <ref> or provide baseReleasesTs')
  const baseTop = readCurrentTopVersion(baseReleasesTs)
  const generatedTop = hasGeneratedTopEntry(releasesTs)
  const divergedFromBase = currentTop !== baseTop

  // Unmarked divergence is not automation-owned. Fail closed — never preserve
  // or overwrite hand edits on the bot sync path.
  if (divergedFromBase && !generatedTop) {
    throw new Error(
      `${RELEASES_TS_PATH} differs from the base without the generated-entry marker; refusing to overwrite it`,
    )
  }

  // Same version without a generated marker is already published on the trusted base.
  if (currentTop === version && !generatedTop)
    return { status: 'unchanged', version, reason: 'releases.ts already lists this version first' }

  const changelog = options.changelog ?? readFileSync(CHANGELOG_PATH, 'utf8')
  const section = parseChangelogSection(changelog, version)
  if (!section) throw new Error(`no CHANGELOG.md section found for version ${version}`)
  if (section.highlights.length === 0)
    throw new Error(`CHANGELOG.md section for ${version} has no bullet highlights`)

  const entry: ReleaseEntry = {
    version: section.version,
    date: section.date,
    theme: deriveTheme(section.highlights),
    highlights: section.highlights,
  }

  // Generated tops are automation-owned: refresh in place for the same version,
  // or replace when the pending Release Please PR bumps the draft version.
  if (generatedTop && (currentTop === version || divergedFromBase))
    return { status: 'updated', version, content: replaceTopReleaseEntry(releasesTs, entry) }

  // New version on top of the trusted base. insertReleaseEntry strips any
  // leftover marker that remained on a previously generated published entry.
  return { status: 'updated', version, content: insertReleaseEntry(releasesTs, entry) }
}

if (import.meta.main) {
  const baseRefIndex = process.argv.indexOf('--base-ref')
  const baseRef = baseRefIndex === -1 ? undefined : process.argv[baseRefIndex + 1]
  if (baseRefIndex !== -1 && (!baseRef || baseRef.startsWith('--')))
    throw new Error('--base-ref requires a git ref argument')
  const result = syncWebReleaseEntry({ baseRef })
  if (result.status === 'unchanged') console.log(`sync-web-release-entry: ${result.reason}`)
  else if (process.argv.includes('--write')) {
    writeFileSync(RELEASES_TS_PATH, result.content)
    console.log(`sync-web-release-entry: inserted ${result.version} into ${RELEASES_TS_PATH}`)
  } else {
    console.error(`sync-web-release-entry: ${RELEASES_TS_PATH} is missing ${result.version}; run with --write`)
    process.exit(1)
  }
}
