import { spawn } from 'node:child_process'
import { createWriteStream, existsSync, statSync } from 'node:fs'
import {
  chmod,
  mkdir,
  readFile,
  rename,
  stat,
  unlink,
  writeFile,
} from 'node:fs/promises'
import path from 'node:path'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import semver from 'semver'
import { notifyUpgradeToast } from './upgrade-toast'
import { getDimcodeUserDir } from './user-cache'
import { resolveCliVersion } from './version'

type UpgradeProvider = 'github' | 'gitlab'

type RuntimeProcess = Pick<
  NodeJS.Process,
  'arch' | 'argv' | 'env' | 'execPath' | 'pid' | 'platform'
>

type RuntimeGlobals = typeof globalThis & {
  Bun?: unknown
  process?: RuntimeProcess
}

declare const __CLI_BINARY_UPDATE_PROVIDER__: string | undefined
declare const __CLI_BINARY_UPDATE_PROJECT__: string | undefined
declare const __CLI_BINARY_UPDATE_GITLAB_HOST__: string | undefined
declare const __CLI_BINARY_UPDATE_REPO__: string | undefined
declare const __CLI_BINARY_UPDATE_GITHUB_API_BASE_URL__: string | undefined

type UpgradeMetaV1 = Readonly<{
  version: 1
  installedAt: number
  cliVersion: string
  target: string
  provider: UpgradeProvider
  project: string
  host?: string
  assetName: string
  binaryPath: string
}>

type ReleaseAsset = Readonly<{ name: string, url: string }>

const META_FILE = 'binary-upgrade.json'
const MIN_BINARY_BYTES = 10000

const DEFAULT_GITLAB_HOST = 'gitlab.bfbops.com'
const DEFAULT_GITLAB_PROJECT = 'nextim/goatagent'
const DEFAULT_GITHUB_REPO = 'nextim/goatagent'
const DEFAULT_GITHUB_API_BASE_URL = 'https://api.github.com'

const RELEASE_CHECK_TIMEOUT_MS = 5000
const DOWNLOAD_TIMEOUT_MS = 30_000
const autoBinaryUpgradeStartedVersions = new Set<string>()

function runtimeGlobals(): RuntimeGlobals {
  return globalThis as RuntimeGlobals
}

function runtimeProcess(): RuntimeProcess | undefined {
  return runtimeGlobals().process
}

function runtimeEnv(): Record<string, string | undefined> {
  return runtimeProcess()?.env ?? {}
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return value != null && typeof value === 'object' && !Array.isArray(value)
}

function getReleaseTimeoutMs(): number {
  const raw = String(
    runtimeEnv().DIMCODE_BINARY_UPDATE_CHECK_TIMEOUT_MS ?? '',
  ).trim()
  const n = Number(raw)
  return Number.isFinite(n) && n > 100
    ? Math.floor(n)
    : RELEASE_CHECK_TIMEOUT_MS
}

function getDownloadTimeoutMs(): number {
  const raw = String(
    runtimeEnv().DIMCODE_BINARY_UPDATE_DOWNLOAD_TIMEOUT_MS ?? '',
  ).trim()
  const n = Number(raw)
  return Number.isFinite(n) && n > 100 ? Math.floor(n) : DOWNLOAD_TIMEOUT_MS
}

export function detectPackageManager(): 'npm' | 'bun' {
  try {
    const execPath = String(runtimeProcess()?.execPath ?? '').trim()
    if (!execPath)
      return 'npm'

    const binDir = path.dirname(execPath).toLowerCase()
    const normalizedDir = binDir.replace(/\\/g, '/')

    if (
      normalizedDir.includes('.bun/bin')
      || normalizedDir.endsWith('.bun/bin')
    ) {
      return 'bun'
    }

    if (normalizedDir.includes('bun/install/global/bin'))
      return 'bun'

    if (
      normalizedDir.includes('.npm-global')
      || normalizedDir.includes('npm-global')
    ) {
      return 'npm'
    }

    if (runtimeGlobals().Bun != null)
      return 'bun'

    return 'npm'
  }
  catch {
    return 'npm'
  }
}

export function isSelfContainedBinary(
  proc: Pick<NodeJS.Process, 'execPath'> = process,
): boolean {
  try {
    const execPath = String(proc.execPath ?? '').trim()
    if (!execPath)
      return false
    const base = path.basename(execPath).toLowerCase()
    if (base === 'node' || base.startsWith('node.'))
      return false
    if (base === 'bun' || base.startsWith('bun.'))
      return false
    return true
  }
  catch {
    return false
  }
}

export function resolveBunTarget(
  platform = process.platform,
  arch = process.arch,
): string | null {
  if (platform === 'darwin' && arch === 'arm64')
    return 'bun-darwin-arm64'
  if (platform === 'darwin' && arch === 'x64')
    return 'bun-darwin-x64'
  if (platform === 'linux' && arch === 'x64')
    return 'bun-linux-x64'
  if (platform === 'linux' && arch === 'arm64')
    return 'bun-linux-arm64'
  if (platform === 'win32' && arch === 'x64')
    return 'bun-windows-x64'
  return null
}

export function resolveAssetName(
  target: string,
  platform = process.platform,
): string {
  const isWindows = platform === 'win32'
  return isWindows ? `dim-${target}.exe` : `dim-${target}`
}

export function normalizeTagToSemver(tag: string): string | null {
  const raw = tag.trim()
  if (!raw)
    return null
  const withoutV = raw.startsWith('v') ? raw.slice(1) : raw
  return semver.valid(withoutV) ? withoutV : null
}

export function sanitizeForFilename(value: string): string {
  const raw = value.trim()
  return raw.replace(/[^\w.-]/g, '_')
}

export function selectGitHubAsset(
  releaseJson: unknown,
  assetName: string,
): ReleaseAsset | null {
  if (!isRecord(releaseJson))
    return null
  const assets = Array.isArray(releaseJson.assets) ? releaseJson.assets : []
  for (const asset of assets) {
    if (!isRecord(asset))
      continue
    const name = typeof asset.name === 'string' ? asset.name : ''
    const url
      = typeof asset.browser_download_url === 'string'
        ? asset.browser_download_url
        : ''
    if (name === assetName && url)
      return { name, url }
  }
  return null
}

export function selectGitLabAsset(
  releaseJson: unknown,
  assetName: string,
): ReleaseAsset | null {
  if (!isRecord(releaseJson) || !isRecord(releaseJson.assets))
    return null
  const links = Array.isArray(releaseJson.assets.links)
    ? releaseJson.assets.links
    : []
  for (const link of links) {
    if (!isRecord(link))
      continue
    const name = typeof link.name === 'string' ? link.name : ''
    const direct
      = typeof link.direct_asset_url === 'string' ? link.direct_asset_url : ''
    const url = typeof link.url === 'string' ? link.url : ''
    const resolved = direct || url
    if (name === assetName && resolved)
      return { name, url: resolved }
  }
  return null
}

export async function maybeRelaunchToUpdatedBinary(): Promise<boolean> {
  const env = runtimeEnv()
  if (env.DIMCODE_BINARY_LAUNCHED === '1')
    return false
  if (!isSelfContainedBinary())
    return false

  const current = resolveCliVersion()?.version
  if (!current || !semver.valid(current))
    return false

  const meta = await readUpgradeMeta(env)
  if (!meta)
    return false

  if (!semver.valid(meta.cliVersion) || !semver.gt(meta.cliVersion, current))
    return false

  if (!isValidBinary(meta.binaryPath))
    return false

  const childEnv: NodeJS.ProcessEnv = {
    ...env,
    DIMCODE_BINARY_LAUNCHED: '1',
  }
  const args = process.argv.slice(1)
  try {
    const child = spawn(meta.binaryPath, args, {
      stdio: 'inherit',
      env: childEnv,
    })
    child.unref()
    return true
  }
  catch {
    return false
  }
}

export async function checkBinaryUpgrade(): Promise<string | null> {
  const env = runtimeEnv()
  if (env.DIMCODE_DISABLE_AUTOUPDATE === '1')
    return null
  if (!isSelfContainedBinary())
    return null

  const current = resolveCliVersion()?.version
  if (!current || !semver.valid(current))
    return null

  const release = await fetchLatestReleaseInfo(env)
  if (!release)
    return null

  if (!semver.valid(release.version) || semver.gte(current, release.version))
    return null

  const auto = env.DIMCODE_AUTOUPDATE !== '0'
  if (auto) {
    if (autoBinaryUpgradeStartedVersions.has(release.version))
      return null
    autoBinaryUpgradeStartedVersions.add(release.version)
    startAutoBinaryUpgrade(release.version)
    return `Update available: dim ${release.version} (current: ${current}). Installing in background; restart to use it.`
  }

  return `Update available: dim ${release.version} (current: ${current}). Run: dim upgrade`
}

export async function runBinaryUpgradeCommand(
  argv: readonly string[],
): Promise<void> {
  const env = runtimeEnv()
  if (!isSelfContainedBinary()) {
    console.error(
      'Binary upgrade is only supported for the standalone compiled dim binary.',
    )
    const pm = detectPackageManager()
    const installCmd = pm === 'bun' ? 'bun' : 'npm'
    console.error(
      `If you installed via ${pm}, run: ${installCmd} install -g dimcode@latest`,
    )
    process.exitCode = 1
    return
  }

  const args = Array.from(argv)
  const quiet = args.includes('--quiet') || args.includes('--background')
  const checkOnly = args.includes('--check')
  const versionArg = firstNonFlag(args)
  const desired = versionArg && versionArg !== 'latest' ? versionArg : 'latest'

  try {
    const info = await fetchReleaseInfo(desired, env)
    if (!info)
      throw new Error('failed to resolve release info')

    const current = resolveCliVersion()?.version
    if (checkOnly) {
      if (
        current
        && semver.valid(current)
        && semver.valid(info.version)
        && semver.gte(current, info.version)
      ) {
        if (!quiet)
          console.log(`Already up to date: ${current}`)
        return
      }
      if (!quiet) {
        console.log(
          `Update available: ${info.version}${current ? ` (current: ${current})` : ''}`,
        )
      }
      return
    }

    const result = await installReleaseAsset(info, env)
    const message = `Update installed: dim ${result.version}. Restart to use the new version.`
    if (!quiet)
      console.log(message)
    notifyUpgradeToast(message)
  }
  catch (error) {
    const msg = error instanceof Error ? error.message : String(error)
    const message = `Binary upgrade failed: ${msg}`
    if (!quiet)
      console.error(message)
    notifyUpgradeToast(message)
    process.exitCode = 1
  }
}

function firstNonFlag(argv: readonly string[]): string | null {
  for (const a of argv) {
    if (!a.startsWith('-'))
      return a
  }
  return null
}

function isValidBinary(filePath: string): boolean {
  try {
    if (!existsSync(filePath))
      return false
    const s = statSync(filePath)
    return s.size > MIN_BINARY_BYTES
  }
  catch {
    return false
  }
}

async function readUpgradeMeta(
  env: Record<string, string | undefined>,
): Promise<UpgradeMetaV1 | null> {
  try {
    const userDir = await getDimcodeUserDir(env)
    const filePath = path.join(userDir, 'bin', META_FILE)
    const raw = await readFile(filePath, 'utf8')
    return parseUpgradeMeta(JSON.parse(raw))
  }
  catch {
    return null
  }
}

function parseUpgradeMeta(value: unknown): UpgradeMetaV1 | null {
  if (!isRecord(value) || value.version !== 1)
    return null

  const cliVersion = typeof value.cliVersion === 'string' ? value.cliVersion : ''
  const binaryPath = typeof value.binaryPath === 'string' ? value.binaryPath : ''
  const target = typeof value.target === 'string' ? value.target : ''
  const provider
    = value.provider === 'github' || value.provider === 'gitlab'
      ? value.provider
      : null
  const project = typeof value.project === 'string' ? value.project : ''
  const assetName = typeof value.assetName === 'string' ? value.assetName : ''
  const installedAt
    = typeof value.installedAt === 'number' && Number.isFinite(value.installedAt)
      ? value.installedAt
      : null
  const host = typeof value.host === 'string' ? value.host : undefined

  if (
    !cliVersion
    || !binaryPath
    || !target
    || !provider
    || !project
    || !assetName
    || installedAt == null
  ) {
    return null
  }

  return {
    version: 1,
    installedAt,
    cliVersion,
    target,
    provider,
    project,
    host,
    assetName,
    binaryPath,
  }
}

async function writeUpgradeMeta(
  env: Record<string, string | undefined>,
  meta: UpgradeMetaV1,
): Promise<void> {
  const userDir = await getDimcodeUserDir(env)
  const binDir = path.join(userDir, 'bin')
  await mkdir(binDir, { recursive: true })
  const filePath = path.join(binDir, META_FILE)
  const pid = runtimeProcess()?.pid ?? 'p'
  const nonce = `${Date.now()}-${Math.random().toString(16).slice(2)}`
  const tmp = `${filePath}.${pid}.${nonce}.tmp`
  await writeFile(tmp, `${JSON.stringify(meta, null, 2)}\n`, {
    encoding: 'utf8',
    mode: 0o600,
  })
  await rename(tmp, filePath)
  try {
    await chmod(filePath, 0o600)
  }
  catch {
    // ignore
  }
}

function resolveProvider(
  env: Record<string, string | undefined>,
): UpgradeProvider {
  const raw = String(env.DIMCODE_BINARY_UPDATE_PROVIDER ?? '')
    .trim()
    .toLowerCase()
  if (raw === 'github')
    return 'github'
  const injected
    = typeof __CLI_BINARY_UPDATE_PROVIDER__ === 'string'
      ? __CLI_BINARY_UPDATE_PROVIDER__.trim().toLowerCase()
      : ''
  if (injected === 'github')
    return 'github'
  return 'gitlab'
}

export function resolveBinaryUpdateConfig(
  env: Record<string, string | undefined>,
): Readonly<{
  provider: UpgradeProvider
  host?: string
  project: string
  githubApiBaseUrl: string
}> {
  const provider = resolveProvider(env)
  const githubApiBaseUrl = resolveGitHubApiBaseUrl(env)
  const projectInfo = resolveProject(env, provider)
  return {
    provider,
    host: projectInfo.host,
    project: projectInfo.project,
    githubApiBaseUrl,
  }
}

function resolveGitHubApiBaseUrl(
  env: Record<string, string | undefined>,
): string {
  const raw = String(env.DIMCODE_BINARY_UPDATE_GITHUB_API_BASE_URL ?? '').trim()
  if (raw)
    return raw.replace(/\/+$/, '')
  const injected
    = typeof __CLI_BINARY_UPDATE_GITHUB_API_BASE_URL__ === 'string'
      ? __CLI_BINARY_UPDATE_GITHUB_API_BASE_URL__.trim()
      : ''
  if (injected)
    return injected.replace(/\/+$/, '')
  return DEFAULT_GITHUB_API_BASE_URL
}

function resolveProject(
  env: Record<string, string | undefined>,
  provider: UpgradeProvider,
): { host?: string, project: string } {
  if (provider === 'github') {
    const injectedRepo
      = typeof __CLI_BINARY_UPDATE_REPO__ === 'string'
        ? __CLI_BINARY_UPDATE_REPO__.trim()
        : ''
    const repo
      = String(
        env.DIMCODE_BINARY_UPDATE_REPO ?? injectedRepo ?? DEFAULT_GITHUB_REPO,
      ).trim() || DEFAULT_GITHUB_REPO
    return { project: repo }
  }
  const injectedHost
    = typeof __CLI_BINARY_UPDATE_GITLAB_HOST__ === 'string'
      ? __CLI_BINARY_UPDATE_GITLAB_HOST__.trim()
      : ''
  const injectedProject
    = typeof __CLI_BINARY_UPDATE_PROJECT__ === 'string'
      ? __CLI_BINARY_UPDATE_PROJECT__.trim()
      : ''
  const host
    = String(
      env.DIMCODE_BINARY_UPDATE_GITLAB_HOST
      ?? injectedHost
      ?? DEFAULT_GITLAB_HOST,
    ).trim() || DEFAULT_GITLAB_HOST
  const project
    = String(
      env.DIMCODE_BINARY_UPDATE_PROJECT
      ?? injectedProject
      ?? DEFAULT_GITLAB_PROJECT,
    ).trim() || DEFAULT_GITLAB_PROJECT
  return { host, project }
}

function resolveAuthHeaders(
  env: Record<string, string | undefined>,
  provider: UpgradeProvider,
): Record<string, string> {
  if (provider === 'github') {
    const token = String(
      env.DIMCODE_GITHUB_TOKEN ?? env.GITHUB_TOKEN ?? '',
    ).trim()
    return token ? { Authorization: `Bearer ${token}` } : {}
  }
  const token = String(
    env.DIMCODE_GITLAB_TOKEN ?? env.GITLAB_TOKEN ?? '',
  ).trim()
  if (!token)
    return {}
  if (token.startsWith('glpat-'))
    return { 'PRIVATE-TOKEN': token }
  return { Authorization: `Bearer ${token}` }
}

async function fetchLatestReleaseInfo(
  env: Record<string, string | undefined>,
): Promise<Readonly<{ version: string }> | null> {
  return await fetchReleaseInfo('latest', env)
}

async function fetchReleaseInfo(
  desired: string,
  env: Record<string, string | undefined>,
): Promise<Readonly<{
  provider: UpgradeProvider
  host?: string
  project: string
  tag: string
  version: string
  asset: ReleaseAsset
  target: string
  assetName: string
}> | null> {
  const config = resolveBinaryUpdateConfig(env)
  const provider = config.provider
  const projectInfo = { host: config.host, project: config.project }
  const headers = resolveAuthHeaders(env, provider)

  const target = resolveBunTarget()
  if (!target)
    return null
  const assetName = resolveAssetName(target)

  const tagCandidates
    = desired === 'latest' ? ['latest'] : buildTagCandidates(desired)

  for (const tagCandidate of tagCandidates) {
    const releaseJson = await fetchReleaseJson({
      provider,
      host: projectInfo.host,
      project: projectInfo.project,
      tag: tagCandidate,
      headers,
      githubApiBaseUrl: config.githubApiBaseUrl,
    })
    if (!releaseJson)
      continue

    const release = isRecord(releaseJson) ? releaseJson : null
    const tag
      = String(release?.tag_name ?? '').trim()
        || (tagCandidate === 'latest' ? 'latest' : tagCandidate)
    const version = normalizeTagToSemver(tag)
    if (!version)
      continue

    const asset
      = provider === 'github'
        ? selectGitHubAsset(releaseJson, assetName)
        : selectGitLabAsset(releaseJson, assetName)
    if (!asset)
      continue

    return {
      provider,
      host: projectInfo.host,
      project: projectInfo.project,
      tag,
      version,
      asset,
      target,
      assetName,
    }
  }

  return null
}

function buildTagCandidates(versionOrTag: string): string[] {
  const raw = versionOrTag.trim()
  if (!raw)
    return []
  if (raw === 'latest')
    return ['latest']
  if (raw.startsWith('v'))
    return [raw, raw.slice(1)]
  return [`v${raw}`, raw]
}

async function fetchReleaseJson(
  opts: Readonly<{
    provider: UpgradeProvider
    host?: string
    project: string
    tag: string
    headers: Record<string, string>
    githubApiBaseUrl: string
  }>,
): Promise<unknown | null> {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), getReleaseTimeoutMs())
  try {
    const url = buildReleaseApiUrl(opts)
    const response = await fetch(url, {
      headers: { Accept: 'application/json', ...opts.headers },
      signal: controller.signal,
    })
    if (!response.ok)
      return null
    return await response.json()
  }
  catch {
    return null
  }
  finally {
    clearTimeout(timeoutId)
  }
}

function buildReleaseApiUrl(
  opts: Readonly<{
    provider: UpgradeProvider
    host?: string
    project: string
    tag: string
    githubApiBaseUrl: string
  }>,
): string {
  if (opts.provider === 'github') {
    const apiBase = String(
      opts.githubApiBaseUrl || DEFAULT_GITHUB_API_BASE_URL,
    ).replace(/\/+$/, '')
    if (opts.tag === 'latest')
      return `${apiBase}/repos/${opts.project}/releases/latest`
    return `${apiBase}/repos/${opts.project}/releases/tags/${encodeURIComponent(opts.tag)}`
  }
  const host = opts.host || DEFAULT_GITLAB_HOST
  const projectId = encodeURIComponent(opts.project)
  if (opts.tag === 'latest')
    return `https://${host}/api/v4/projects/${projectId}/releases/permalink/latest`
  return `https://${host}/api/v4/projects/${projectId}/releases/${encodeURIComponent(opts.tag)}`
}

function startAutoBinaryUpgrade(version: string): void {
  try {
    const env: NodeJS.ProcessEnv = {
      ...runtimeEnv(),
      DIMCODE_BINARY_UPGRADE_CHILD: '1',
    }
    const proc = spawn(process.execPath, ['upgrade', version, '--background'], {
      stdio: 'ignore',
      detached: true,
      env,
    })
    proc.unref()
  }
  catch {
    // ignore
  }
}

async function installReleaseAsset(
  info: Readonly<{
    provider: UpgradeProvider
    host?: string
    project: string
    version: string
    target: string
    assetName: string
    asset: ReleaseAsset
  }>,
  env: Record<string, string | undefined>,
): Promise<Readonly<{ version: string, binaryPath: string }>> {
  const userDir = await getDimcodeUserDir(env)
  const binDir = path.join(userDir, 'bin')
  await mkdir(binDir, { recursive: true })

  const ext = process.platform === 'win32' ? '.exe' : ''
  const versionSuffix = sanitizeForFilename(info.version)
  const binaryPath = path.join(
    binDir,
    `dim-${info.target}-${versionSuffix}${ext}`,
  )

  if (isValidBinary(binaryPath)) {
    const meta: UpgradeMetaV1 = {
      version: 1,
      installedAt: Date.now(),
      cliVersion: info.version,
      target: info.target,
      provider: info.provider,
      project: info.project,
      host: info.host,
      assetName: info.assetName,
      binaryPath,
    }
    await writeUpgradeMeta(env, meta)
    return { version: info.version, binaryPath }
  }

  const tmp = `${binaryPath}.${process.pid}.${Date.now()}.tmp`
  await downloadToFile(
    info.asset.url,
    tmp,
    resolveAuthHeaders(env, info.provider),
  )

  if (process.platform !== 'win32') {
    try {
      await chmod(tmp, 0o755)
    }
    catch {
      // ignore
    }
  }

  try {
    await unlink(binaryPath)
  }
  catch {
    // ignore
  }
  await rename(tmp, binaryPath)
  try {
    if (process.platform !== 'win32')
      await chmod(binaryPath, 0o755)
  }
  catch {
    // ignore
  }

  const meta: UpgradeMetaV1 = {
    version: 1,
    installedAt: Date.now(),
    cliVersion: info.version,
    target: info.target,
    provider: info.provider,
    project: info.project,
    host: info.host,
    assetName: info.assetName,
    binaryPath,
  }
  await writeUpgradeMeta(env, meta)

  return { version: info.version, binaryPath }
}

async function downloadToFile(
  url: string,
  outPath: string,
  headers: Record<string, string>,
): Promise<void> {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), getDownloadTimeoutMs())
  try {
    const response = await fetch(url, {
      redirect: 'follow',
      headers,
      signal: controller.signal,
    })
    if (!response.ok)
      throw new Error(`download failed (${response.status})`)

    const body = response.body
    if (!body)
      throw new Error('download response has no body')

    await pipeline(
      Readable.fromWeb(body as globalThis.ReadableStream<Uint8Array>),
      createWriteStream(outPath),
    )

    const s = await stat(outPath)
    if (s.size <= MIN_BINARY_BYTES)
      throw new Error('downloaded file is too small')
  }
  finally {
    clearTimeout(timeoutId)
  }
}
