import type * as types from "../shared/types"
import * as common from "../shared/common"
import * as ourselves from "./mod"
import * as denoflate from "https://deno.land/x/denoflate@1.2.1/mod.ts"

declare const ESBUILD_VERSION: string
declare const ESBUILD_BINARY_HASHES: Record<string, string>

export let version = ESBUILD_VERSION

export let build: typeof types.build = (options: types.BuildOptions) =>
  ensureServiceIsRunning().then(service =>
    service.build(options))

export const context: typeof types.context = (options: types.BuildOptions) =>
  ensureServiceIsRunning().then(service =>
    service.context(options))

export const transform: typeof types.transform = (input: string | Uint8Array, options?: types.TransformOptions) =>
  ensureServiceIsRunning().then(service =>
    service.transform(input, options))

export const formatMessages: typeof types.formatMessages = (messages, options) =>
  ensureServiceIsRunning().then(service =>
    service.formatMessages(messages, options))

export const analyzeMetafile: typeof types.analyzeMetafile = (metafile, options) =>
  ensureServiceIsRunning().then(service =>
    service.analyzeMetafile(metafile, options))

export const buildSync: typeof types.buildSync = () => {
  throw new Error(`The "buildSync" API does not work in Deno`)
}

export const transformSync: typeof types.transformSync = () => {
  throw new Error(`The "transformSync" API does not work in Deno`)
}

export const formatMessagesSync: typeof types.formatMessagesSync = () => {
  throw new Error(`The "formatMessagesSync" API does not work in Deno`)
}

export const analyzeMetafileSync: typeof types.analyzeMetafileSync = () => {
  throw new Error(`The "analyzeMetafileSync" API does not work in Deno`)
}

export const stop = async () => {
  if (stopService) await stopService()
}

let initializeWasCalled = false

export const initialize: typeof types.initialize = async (options) => {
  options = common.validateInitializeOptions(options || {})
  if (options.wasmURL) throw new Error(`The "wasmURL" option only works in the browser`)
  if (options.wasmModule) throw new Error(`The "wasmModule" option only works in the browser`)
  if (options.worker) throw new Error(`The "worker" option only works in the browser`)
  if (initializeWasCalled) throw new Error('Cannot call "initialize" more than once')
  await ensureServiceIsRunning()
  initializeWasCalled = true
}

async function binaryIntegrityCheck(pkg: string, subpath: string, bytes: Uint8Array): Promise<void> {
  const hashBuffer = await crypto.subtle.digest('SHA-256', bytes as Uint8Array<ArrayBuffer>)
  const hash = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
  const key = `${pkg}/${subpath}`
  const expected = ESBUILD_BINARY_HASHES[key]
  if (!expected) throw new Error(`Missing hash for "${key}"`)
  if (hash !== expected) throw new Error(`"${hash.slice(0, 8)}..." doesn't match "${expected.slice(0, 8)}..." for "${pkg}"`)
}

async function installFromNPM(name: string, subpath: string): Promise<string> {
  const { finalPath, finalDir } = getCachePath(name)
  try {
    await Deno.stat(finalPath)
    return finalPath
  } catch (e) {
  }

  const npmRegistry = Deno.env.get("NPM_CONFIG_REGISTRY") || "https://registry.npmjs.org"
  const url = `${npmRegistry}/${name}/-/${name.replace("@esbuild/", "")}-${version}.tgz`
  const buffer = await fetch(url).then(r => r.arrayBuffer())
  const executable = extractFileFromTarGzip(new Uint8Array(buffer), subpath)
  await binaryIntegrityCheck(name, subpath, executable)

  await Deno.mkdir(finalDir, {
    recursive: true,
    mode: 0o700, // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
  })

  await Deno.writeFile(finalPath, executable, { mode: 0o755 })
  return finalPath
}

function getCachePath(name: string): { finalPath: string, finalDir: string } {
  let baseDir: string | undefined

  switch (Deno.build.os) {
    case 'darwin':
      baseDir = Deno.env.get('HOME')
      if (baseDir) baseDir += '/Library/Caches'
      break

    case 'windows':
      baseDir = Deno.env.get('LOCALAPPDATA')
      if (!baseDir) {
        baseDir = Deno.env.get('USERPROFILE')
        if (baseDir) baseDir += '/AppData/Local'
      }
      if (baseDir) baseDir += '/Cache'
      break

    case 'linux':
      // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
      const xdg = Deno.env.get('XDG_CACHE_HOME')
      if (xdg && xdg[0] === '/') baseDir = xdg
      break
  }

  if (!baseDir) {
    baseDir = Deno.env.get('HOME')
    if (baseDir) baseDir += '/.cache'
  }

  if (!baseDir) throw new Error('Failed to find cache directory')
  const finalDir = baseDir + `/esbuild/bin`
  const finalPath = finalDir + `/${name.replace('/', '-')}@${version}`
  return { finalPath, finalDir }
}

function extractFileFromTarGzip(buffer: Uint8Array, file: string): Uint8Array {
  try {
    buffer = denoflate.gunzip(buffer)
  } catch (err: any) {
    throw new Error(`Invalid gzip data in archive: ${err && err.message || err}`)
  }
  let str = (i: number, n: number) => String.fromCharCode(...buffer.subarray(i, i + n)).replace(/\0.*$/, '')
  let offset = 0
  file = `package/${file}`
  while (offset < buffer.length) {
    let name = str(offset, 100)
    let size = parseInt(str(offset + 124, 12), 8)
    offset += 512
    if (!isNaN(size) && size >= 0) {
      if (name === file) return buffer.subarray(offset, offset + size)
      offset += (size + 511) & ~511
    }
  }
  throw new Error(`Could not find ${JSON.stringify(file)} in archive`)
}

async function install(): Promise<string> {
  const overridePath = Deno.env.get('ESBUILD_BINARY_PATH')
  if (overridePath) return overridePath

  const platformKey = Deno.build.target
  const knownWindowsPackages: Record<string, string> = {
    'x86_64-pc-windows-msvc': '@esbuild/win32-x64',
  }
  const knownUnixlikePackages: Record<string, string> = {
    // These are the only platforms that Deno supports
    'aarch64-apple-darwin': '@esbuild/darwin-arm64',
    'aarch64-unknown-linux-gnu': '@esbuild/linux-arm64',
    'x86_64-apple-darwin': '@esbuild/darwin-x64',
    'x86_64-unknown-linux-gnu': '@esbuild/linux-x64',

    // These platforms are not supported by Deno
    'aarch64-linux-android': '@esbuild/android-arm64',
    'x86_64-unknown-freebsd': '@esbuild/freebsd-x64',
    "x86_64-alpine-linux-musl": '@esbuild/linux-x64',
  }

  // Pick a package to install
  if (platformKey in knownWindowsPackages) {
    return await installFromNPM(knownWindowsPackages[platformKey], 'esbuild.exe')
  } else if (platformKey in knownUnixlikePackages) {
    return await installFromNPM(knownUnixlikePackages[platformKey], 'bin/esbuild')
  } else {
    throw new Error(`Unsupported platform: ${platformKey}`)
  }
}

interface Service {
  build: typeof types.build
  context: typeof types.context
  transform: typeof types.transform
  formatMessages: typeof types.formatMessages
  analyzeMetafile: typeof types.analyzeMetafile
}

let defaultWD = Deno.cwd()
let longLivedService: Promise<Service> | undefined
let stopService: (() => Promise<void>) | undefined

// Declare a common subprocess API for the two implementations below
type SpawnFn = (cmd: string, options: {
  args: string[]
  stdin: 'piped' | 'inherit'
  stdout: 'piped' | 'inherit'
  stderr: 'inherit'
}) => {
  write(bytes: Uint8Array): void
  read(): Promise<Uint8Array | null>
  close(): Promise<void> | void
  status(): Promise<{ code: number }>
}

// Deno ≥1.40
const spawnNew: SpawnFn = (cmd, { args, stdin, stdout, stderr }) => {
  const child = new Deno.Command(cmd, {
    args,
    cwd: defaultWD,
    stdin,
    stdout,
    stderr,
  }).spawn()
  // Note: Need to check for "piped" in Deno ≥1.31.0 to avoid a crash
  const writer = stdin === 'piped' ? child.stdin.getWriter() : null
  const reader = stdout === 'piped' ? child.stdout.getReader() : null
  return {
    write: writer ? bytes => writer.write(bytes) : () => Promise.resolve(),
    read: reader ? () => reader.read().then(x => x.value || null) : () => Promise.resolve(null),
    close: async () => {
      // We can't call "kill()" because it doesn't seem to work. Tests will
      // still fail with "A child process was opened during the test, but not
      // closed during the test" even though we kill the child process.
      //
      // And we can't call both "writer.close()" and "kill()" because then
      // there's a race as the child process exits when stdin is closed, and
      // "kill()" fails when the child process has already been killed.
      //
      // So instead we just call "writer.close()" and then hope that this
      // causes the child process to exit. It won't work if the stdin consumer
      // thread in the child process is hung or busy, but that may be the best
      // we can do.
      //
      // See this for more info: https://github.com/evanw/esbuild/pull/3611
      if (writer) await writer.close()
      if (reader) await reader.cancel()

      // Wait for the process to exit. The new "kill()" API doesn't flag the
      // process as having exited because processes can technically ignore the
      // kill signal. Without this, Deno will fail tests that use esbuild with
      // an error because the test spawned a process but didn't wait for it.
      await child.status
    },
    status: () => child.status,
  }
}

// Deno <1.40
const spawnOld: SpawnFn = (cmd, { args, stdin, stdout, stderr }) => {
  const child = Deno.run({
    cmd: [cmd].concat(args),
    cwd: defaultWD,
    stdin,
    stdout,
    stderr,
  })
  const stdoutBuffer = new Uint8Array(4 * 1024 * 1024)
  let writeQueue: Uint8Array[] = []
  let isQueueLocked = false

  // We need to keep calling "write()" until it actually writes the data
  const startWriteFromQueueWorker = () => {
    if (isQueueLocked || writeQueue.length === 0) return
    isQueueLocked = true
    child.stdin!.write(writeQueue[0]).then(bytesWritten => {
      isQueueLocked = false
      if (bytesWritten === writeQueue[0].length) writeQueue.shift()
      else writeQueue[0] = writeQueue[0].subarray(bytesWritten)
      startWriteFromQueueWorker()
    })
  }

  return {
    write: bytes => {
      writeQueue.push(bytes)
      startWriteFromQueueWorker()
    },
    read: () => child.stdout!.read(stdoutBuffer).then(n => n === null ? null : stdoutBuffer.subarray(0, n)),
    close: () => {
      child.stdin!.close()
      child.stdout!.close()
      child.close()
    },
    status: () => child.status(),
  }
}

// This is a shim for "Deno.run" for newer versions of Deno
const spawn: SpawnFn = Deno.Command ? spawnNew : spawnOld

const ensureServiceIsRunning = (): Promise<Service> => {
  if (!longLivedService) {
    longLivedService = (async (): Promise<Service> => {
      const binPath = await install()
      const isTTY = Deno.stderr.isTerminal
        ? Deno.stderr.isTerminal() // Deno ≥1.40
        : Deno.isatty(Deno.stderr.rid) // Deno <1.40

      const child = spawn(binPath, {
        args: [`--service=${version}`],
        stdin: 'piped',
        stdout: 'piped',
        stderr: 'inherit',
      })

      stopService = async () => {
        // Close all resources related to the subprocess.
        await child.close()
        initializeWasCalled = false
        longLivedService = undefined
        stopService = undefined
      }

      const { readFromStdout, afterClose, service } = common.createChannel({
        writeToStdin(bytes) {
          child.write(bytes)
        },
        isSync: false,
        hasFS: true,
        esbuild: ourselves,
      })

      const readMoreStdout = () => child.read().then(buffer => {
        if (buffer === null) {
          afterClose(null)
        } else {
          readFromStdout(buffer)
          readMoreStdout()
        }
      }).catch(e => {
        if (e instanceof Deno.errors.Interrupted || e instanceof Deno.errors.BadResource) {
          // ignore the error if read was interrupted (stdout was closed)
          afterClose(e)
        } else {
          throw e
        }
      })
      readMoreStdout()

      return {
        build: (options: types.BuildOptions) =>
          new Promise<types.BuildResult>((resolve, reject) => {
            service.buildOrContext({
              callName: 'build',
              refs: null,
              options,
              isTTY,
              defaultWD,
              callback: (err, res) => err ? reject(err) : resolve(res as types.BuildResult),
            })
          }),

        context: (options: types.BuildOptions) =>
          new Promise<types.BuildContext>((resolve, reject) =>
            service.buildOrContext({
              callName: 'context',
              refs: null,
              options,
              isTTY,
              defaultWD,
              callback: (err, res) => err ? reject(err) : resolve(res as types.BuildContext),
            })),

        transform: (input: string | Uint8Array, options?: types.TransformOptions) =>
          new Promise<types.TransformResult>((resolve, reject) =>
            service.transform({
              callName: 'transform',
              refs: null,
              input,
              options: options || {},
              isTTY,
              fs: {
                readFile(tempFile, callback) {
                  Deno.readFile(tempFile).then(
                    bytes => {
                      let text = new TextDecoder().decode(bytes)
                      try {
                        Deno.remove(tempFile)
                      } catch (e) {
                      }
                      callback(null, text)
                    },
                    err => callback(err, null),
                  )
                },
                writeFile(contents, callback) {
                  Deno.makeTempFile().then(
                    tempFile => Deno.writeFile(tempFile, typeof contents === 'string' ? new TextEncoder().encode(contents) : contents).then(
                      () => callback(tempFile),
                      () => callback(null)),
                    () => callback(null))
                },
              },
              callback: (err, res) => err ? reject(err) : resolve(res!),
            })),

        formatMessages: (messages, options) =>
          new Promise((resolve, reject) =>
            service.formatMessages({
              callName: 'formatMessages',
              refs: null,
              messages,
              options,
              callback: (err, res) => err ? reject(err) : resolve(res!),
            })),

        analyzeMetafile: (metafile, options) =>
          new Promise((resolve, reject) =>
            service.analyzeMetafile({
              callName: 'analyzeMetafile',
              refs: null,
              metafile: typeof metafile === 'string' ? metafile : JSON.stringify(metafile),
              options,
              callback: (err, res) => err ? reject(err) : resolve(res!),
            })),
      }
    })()
  }
  return longLivedService
}

// If we're called as the main script, forward the CLI to the underlying executable
if (import.meta.main) {
  spawn(await install(), {
    args: Deno.args,
    stdin: 'inherit',
    stdout: 'inherit',
    stderr: 'inherit',
  }).status().then(({ code }) => {
    Deno.exit(code)
  })
}
