import * as types from "./types";
import * as protocol from "./stdio_protocol";

declare const ESBUILD_VERSION: string;

function validateTarget(target: string): string {
  target += ''
  if (target.indexOf(',') >= 0) throw new Error(`Invalid target: ${target}`)
  return target
}

let canBeAnything = () => null;

let mustBeBoolean = (value: boolean | undefined): string | null =>
  typeof value === 'boolean' ? null : 'a boolean';

let mustBeBooleanOrObject = (value: Object | boolean | undefined): string | null =>
  typeof value === 'boolean' || (typeof value === 'object' && !Array.isArray(value)) ? null : 'a boolean or an object';

let mustBeString = (value: string | undefined): string | null =>
  typeof value === 'string' ? null : 'a string';

let mustBeRegExp = (value: RegExp | undefined): string | null =>
  value instanceof RegExp ? null : 'a RegExp object';

let mustBeInteger = (value: number | undefined): string | null =>
  typeof value === 'number' && value === (value | 0) ? null : 'an integer';

let mustBeFunction = (value: Function | undefined): string | null =>
  typeof value === 'function' ? null : 'a function';

let mustBeArray = <T>(value: T[] | undefined): string | null =>
  Array.isArray(value) ? null : 'an array';

let mustBeObject = (value: Object | undefined): string | null =>
  typeof value === 'object' && value !== null && !Array.isArray(value) ? null : 'an object';

let mustBeObjectOrNull = (value: Object | null | undefined): string | null =>
  typeof value === 'object' && !Array.isArray(value) ? null : 'an object or null';

let mustBeStringOrBoolean = (value: string | boolean | undefined): string | null =>
  typeof value === 'string' || typeof value === 'boolean' ? null : 'a string or a boolean';

let mustBeStringOrObject = (value: string | Object | undefined): string | null =>
  typeof value === 'string' || typeof value === 'object' && value !== null && !Array.isArray(value) ? null : 'a string or an object';

let mustBeStringOrArray = (value: string | string[] | undefined): string | null =>
  typeof value === 'string' || Array.isArray(value) ? null : 'a string or an array';

let mustBeStringOrUint8Array = (value: string | Uint8Array | undefined): string | null =>
  typeof value === 'string' || value instanceof Uint8Array ? null : 'a string or a Uint8Array';

type OptionKeys = { [key: string]: boolean };

function getFlag<T, K extends keyof T>(object: T, keys: OptionKeys, key: K, mustBeFn: (value: T[K]) => string | null): T[K] | undefined {
  let value = object[key];
  keys[key + ''] = true;
  if (value === undefined) return undefined;
  let mustBe = mustBeFn(value);
  if (mustBe !== null) throw new Error(`"${key}" must be ${mustBe}`);
  return value;
}

function checkForInvalidFlags(object: Object, keys: OptionKeys, where: string): void {
  for (let key in object) {
    if (!(key in keys)) {
      throw new Error(`Invalid option ${where}: "${key}"`);
    }
  }
}

export function validateServiceOptions(options: types.ServiceOptions): types.ServiceOptions {
  let keys: OptionKeys = Object.create(null);
  let wasmURL = getFlag(options, keys, 'wasmURL', mustBeString);
  let worker = getFlag(options, keys, 'worker', mustBeBoolean);
  checkForInvalidFlags(options, keys, 'in startService() call');
  return {
    wasmURL,
    worker,
  };
}

type CommonOptions = types.BuildOptions | types.TransformOptions;

function pushLogFlags(flags: string[], options: CommonOptions, keys: OptionKeys, isTTY: boolean, logLevelDefault: types.LogLevel): void {
  let color = getFlag(options, keys, 'color', mustBeBoolean);
  let logLevel = getFlag(options, keys, 'logLevel', mustBeString);
  let errorLimit = getFlag(options, keys, 'errorLimit', mustBeInteger);

  if (color) flags.push(`--color=${color}`);
  else if (isTTY) flags.push(`--color=true`); // This is needed to fix "execFileSync" which buffers stderr
  flags.push(`--log-level=${logLevel || logLevelDefault}`);
  flags.push(`--error-limit=${errorLimit || 0}`);
}

function pushCommonFlags(flags: string[], options: CommonOptions, keys: OptionKeys): void {
  let sourcesContent = getFlag(options, keys, 'sourcesContent', mustBeBoolean);
  let target = getFlag(options, keys, 'target', mustBeStringOrArray);
  let format = getFlag(options, keys, 'format', mustBeString);
  let globalName = getFlag(options, keys, 'globalName', mustBeString);
  let minify = getFlag(options, keys, 'minify', mustBeBoolean);
  let minifySyntax = getFlag(options, keys, 'minifySyntax', mustBeBoolean);
  let minifyWhitespace = getFlag(options, keys, 'minifyWhitespace', mustBeBoolean);
  let minifyIdentifiers = getFlag(options, keys, 'minifyIdentifiers', mustBeBoolean);
  let charset = getFlag(options, keys, 'charset', mustBeString);
  let treeShaking = getFlag(options, keys, 'treeShaking', mustBeStringOrBoolean);
  let jsxFactory = getFlag(options, keys, 'jsxFactory', mustBeString);
  let jsxFragment = getFlag(options, keys, 'jsxFragment', mustBeString);
  let define = getFlag(options, keys, 'define', mustBeObject);
  let pure = getFlag(options, keys, 'pure', mustBeArray);
  let avoidTDZ = getFlag(options, keys, 'avoidTDZ', mustBeBoolean);
  let keepNames = getFlag(options, keys, 'keepNames', mustBeBoolean);
  let banner = getFlag(options, keys, 'banner', mustBeString);
  let footer = getFlag(options, keys, 'footer', mustBeString);

  if (sourcesContent !== void 0) flags.push(`--sources-content=${sourcesContent}`);
  if (target) {
    if (Array.isArray(target)) flags.push(`--target=${Array.from(target).map(validateTarget).join(',')}`)
    else flags.push(`--target=${validateTarget(target)}`)
  }
  if (format) flags.push(`--format=${format}`);
  if (globalName) flags.push(`--global-name=${globalName}`);

  if (minify) flags.push('--minify');
  if (minifySyntax) flags.push('--minify-syntax');
  if (minifyWhitespace) flags.push('--minify-whitespace');
  if (minifyIdentifiers) flags.push('--minify-identifiers');
  if (charset) flags.push(`--charset=${charset}`);
  if (treeShaking !== void 0 && treeShaking !== true) flags.push(`--tree-shaking=${treeShaking}`);

  if (jsxFactory) flags.push(`--jsx-factory=${jsxFactory}`);
  if (jsxFragment) flags.push(`--jsx-fragment=${jsxFragment}`);
  if (define) {
    for (let key in define) {
      if (key.indexOf('=') >= 0) throw new Error(`Invalid define: ${key}`);
      flags.push(`--define:${key}=${define[key]}`);
    }
  }
  if (pure) for (let fn of pure) flags.push(`--pure:${fn}`);
  if (avoidTDZ) flags.push(`--avoid-tdz`);
  if (keepNames) flags.push(`--keep-names`);

  if (banner) flags.push(`--banner=${banner}`);
  if (footer) flags.push(`--footer=${footer}`);
}

function flagsForBuildOptions(
  callName: string,
  options: types.BuildOptions,
  isTTY: boolean,
  logLevelDefault: types.LogLevel,
  writeDefault: boolean,
): {
  flags: string[],
  write: boolean,
  plugins: types.Plugin[] | undefined,
  stdinContents: string | null,
  stdinResolveDir: string | null,
  absWorkingDir: string | undefined,
  incremental: boolean,
  watch: types.WatchMode | null,
} {
  let flags: string[] = [];
  let keys: OptionKeys = Object.create(null);
  let stdinContents: string | null = null;
  let stdinResolveDir: string | null = null;
  let watchMode: types.WatchMode | null = null;
  pushLogFlags(flags, options, keys, isTTY, logLevelDefault);
  pushCommonFlags(flags, options, keys);

  let sourcemap = getFlag(options, keys, 'sourcemap', mustBeStringOrBoolean);
  let bundle = getFlag(options, keys, 'bundle', mustBeBoolean);
  let watch = getFlag(options, keys, 'watch', mustBeBooleanOrObject);
  let splitting = getFlag(options, keys, 'splitting', mustBeBoolean);
  let metafile = getFlag(options, keys, 'metafile', mustBeString);
  let outfile = getFlag(options, keys, 'outfile', mustBeString);
  let outdir = getFlag(options, keys, 'outdir', mustBeString);
  let outbase = getFlag(options, keys, 'outbase', mustBeString);
  let platform = getFlag(options, keys, 'platform', mustBeString);
  let tsconfig = getFlag(options, keys, 'tsconfig', mustBeString);
  let resolveExtensions = getFlag(options, keys, 'resolveExtensions', mustBeArray);
  let mainFields = getFlag(options, keys, 'mainFields', mustBeArray);
  let external = getFlag(options, keys, 'external', mustBeArray);
  let loader = getFlag(options, keys, 'loader', mustBeObject);
  let outExtension = getFlag(options, keys, 'outExtension', mustBeObject);
  let publicPath = getFlag(options, keys, 'publicPath', mustBeString);
  let inject = getFlag(options, keys, 'inject', mustBeArray);
  let entryPoints = getFlag(options, keys, 'entryPoints', mustBeArray);
  let absWorkingDir = getFlag(options, keys, 'absWorkingDir', mustBeString);
  let stdin = getFlag(options, keys, 'stdin', mustBeObject);
  let write = getFlag(options, keys, 'write', mustBeBoolean) ?? writeDefault; // Default to true if not specified
  let incremental = getFlag(options, keys, 'incremental', mustBeBoolean) === true;
  let plugins = getFlag(options, keys, 'plugins', mustBeArray);
  checkForInvalidFlags(options, keys, `in ${callName}() call`);

  if (sourcemap) flags.push(`--sourcemap${sourcemap === true ? '' : `=${sourcemap}`}`);
  if (bundle) flags.push('--bundle');
  if (watch) {
    flags.push('--watch');
    if (typeof watch === 'boolean') {
      watchMode = {};
    } else {
      let watchKeys: OptionKeys = Object.create(null);
      let onRebuild = getFlag(watch, watchKeys, 'onRebuild', mustBeFunction);
      checkForInvalidFlags(watch, watchKeys, `on "watch" in ${callName}() call`);
      watchMode = { onRebuild };
    }
  }
  if (splitting) flags.push('--splitting');
  if (metafile) flags.push(`--metafile=${metafile}`);
  if (outfile) flags.push(`--outfile=${outfile}`);
  if (outdir) flags.push(`--outdir=${outdir}`);
  if (outbase) flags.push(`--outbase=${outbase}`);
  if (platform) flags.push(`--platform=${platform}`);
  if (tsconfig) flags.push(`--tsconfig=${tsconfig}`);
  if (resolveExtensions) {
    let values: string[] = [];
    for (let value of resolveExtensions) {
      value += '';
      if (value.indexOf(',') >= 0) throw new Error(`Invalid resolve extension: ${value}`);
      values.push(value);
    }
    flags.push(`--resolve-extensions=${values.join(',')}`);
  }
  if (publicPath) flags.push(`--public-path=${publicPath}`);
  if (mainFields) {
    let values: string[] = [];
    for (let value of mainFields) {
      value += '';
      if (value.indexOf(',') >= 0) throw new Error(`Invalid main field: ${value}`);
      values.push(value);
    }
    flags.push(`--main-fields=${values.join(',')}`);
  }
  if (external) for (let name of external) flags.push(`--external:${name}`);
  if (inject) for (let path of inject) flags.push(`--inject:${path}`);
  if (loader) {
    for (let ext in loader) {
      if (ext.indexOf('=') >= 0) throw new Error(`Invalid loader extension: ${ext}`);
      flags.push(`--loader:${ext}=${loader[ext]}`);
    }
  }
  if (outExtension) {
    for (let ext in outExtension) {
      if (ext.indexOf('=') >= 0) throw new Error(`Invalid out extension: ${ext}`);
      flags.push(`--out-extension:${ext}=${outExtension[ext]}`);
    }
  }

  if (entryPoints) {
    for (let entryPoint of entryPoints) {
      entryPoint += '';
      if (entryPoint.startsWith('-')) throw new Error(`Invalid entry point: ${entryPoint}`);
      flags.push(entryPoint);
    }
  }

  if (stdin) {
    let stdinKeys: OptionKeys = Object.create(null);
    let contents = getFlag(stdin, stdinKeys, 'contents', mustBeString);
    let resolveDir = getFlag(stdin, stdinKeys, 'resolveDir', mustBeString);
    let sourcefile = getFlag(stdin, stdinKeys, 'sourcefile', mustBeString);
    let loader = getFlag(stdin, stdinKeys, 'loader', mustBeString);
    checkForInvalidFlags(stdin, stdinKeys, 'in "stdin" object');

    if (sourcefile) flags.push(`--sourcefile=${sourcefile}`);
    if (loader) flags.push(`--loader=${loader}`);
    if (resolveDir) stdinResolveDir = resolveDir + '';
    stdinContents = contents ? contents + '' : '';
  }

  return {
    flags,
    write,
    plugins,
    stdinContents,
    stdinResolveDir,
    absWorkingDir,
    incremental,
    watch: watchMode,
  };
}

function flagsForTransformOptions(
  callName: string,
  options: types.TransformOptions,
  isTTY: boolean,
  logLevelDefault: types.LogLevel,
): string[] {
  let flags: string[] = [];
  let keys: OptionKeys = Object.create(null);
  pushLogFlags(flags, options, keys, isTTY, logLevelDefault);
  pushCommonFlags(flags, options, keys);

  let sourcemap = getFlag(options, keys, 'sourcemap', mustBeStringOrBoolean);
  let tsconfigRaw = getFlag(options, keys, 'tsconfigRaw', mustBeStringOrObject);
  let sourcefile = getFlag(options, keys, 'sourcefile', mustBeString);
  let loader = getFlag(options, keys, 'loader', mustBeString);
  checkForInvalidFlags(options, keys, `in ${callName}() call`);

  if (sourcemap) flags.push(`--sourcemap=${sourcemap === true ? 'external' : sourcemap}`);
  if (tsconfigRaw) flags.push(`--tsconfig-raw=${typeof tsconfigRaw === 'string' ? tsconfigRaw : JSON.stringify(tsconfigRaw)}`);
  if (sourcefile) flags.push(`--sourcefile=${sourcefile}`);
  if (loader) flags.push(`--loader=${loader}`);

  return flags;
}

export interface StreamIn {
  writeToStdin: (data: Uint8Array) => void;
  readFileSync?: (path: string, encoding: 'utf8') => string;
  isSync: boolean;
  isBrowser: boolean;
}

export interface StreamOut {
  readFromStdout: (data: Uint8Array) => void;
  afterClose: () => void;
  service: StreamService;
}

export interface StreamFS {
  writeFile(contents: string, callback: (path: string | null) => void): void;
  readFile(path: string, callback: (err: Error | null, contents: string | null) => void): void;
}

export interface Refs {
  ref(): void;
  unref(): void;
}

export interface StreamService {
  buildOrServe(
    callName: string,
    refs: Refs | null,
    serveOptions: types.ServeOptions | null,
    options: types.BuildOptions,
    isTTY: boolean,
    defaultWD: string,
    callback: (err: Error | null, res: types.BuildResult | types.ServeResult | null) => void,
  ): void;

  transform(
    callName: string,
    refs: Refs | null,
    input: string,
    options: types.TransformOptions,
    isTTY: boolean,
    fs: StreamFS,
    callback: (err: Error | null, res: types.TransformResult | null) => void,
  ): void;
}

// This can't use any promises in the main execution flow because it must work
// for both sync and async code. There is an exception for plugin code because
// that can't work in sync code anyway.
export function createChannel(streamIn: StreamIn): StreamOut {
  type PluginCallback = (request: protocol.OnResolveRequest | protocol.OnLoadRequest) =>
    Promise<protocol.OnResolveResponse | protocol.OnLoadResponse>;

  type WatchCallback = (error: Error | null, response: any) => void;

  interface ServeCallbacks {
    onRequest: types.ServeOptions['onRequest'];
    onWait: (error: string | null) => void;
  }

  let responseCallbacks = new Map<number, (error: string | null, response: protocol.Value) => void>();
  let pluginCallbacks = new Map<number, PluginCallback>();
  let watchCallbacks = new Map<number, WatchCallback>();
  let serveCallbacks = new Map<number, ServeCallbacks>();
  let nextServeID = 0;
  let isClosed = false;
  let nextRequestID = 0;
  let nextBuildKey = 0;

  // Use a long-lived buffer to store stdout data
  let stdout = new Uint8Array(16 * 1024);
  let stdoutUsed = 0;
  let readFromStdout = (chunk: Uint8Array) => {
    // Append the chunk to the stdout buffer, growing it as necessary
    let limit = stdoutUsed + chunk.length;
    if (limit > stdout.length) {
      let swap = new Uint8Array(limit * 2);
      swap.set(stdout);
      stdout = swap;
    }
    stdout.set(chunk, stdoutUsed);
    stdoutUsed += chunk.length;

    // Process all complete (i.e. not partial) packets
    let offset = 0;
    while (offset + 4 <= stdoutUsed) {
      let length = protocol.readUInt32LE(stdout, offset);
      if (offset + 4 + length > stdoutUsed) {
        break;
      }
      offset += 4;
      handleIncomingPacket(stdout.slice(offset, offset + length));
      offset += length;
    }
    if (offset > 0) {
      stdout.set(stdout.slice(offset));
      stdoutUsed -= offset;
    }
  };

  let afterClose = () => {
    // When the process is closed, fail all pending requests
    isClosed = true;
    for (let callback of responseCallbacks.values()) {
      callback('The service was stopped', null);
    }
    responseCallbacks.clear();
    for (let callbacks of serveCallbacks.values()) {
      callbacks.onWait('The service was stopped');
    }
    serveCallbacks.clear();
    for (let callback of watchCallbacks.values()) {
      try {
        callback(new Error('The service was stopped'), null);
      } catch (e) {
        console.error(e)
      }
    }
    watchCallbacks.clear();
  };

  let sendRequest = <Req, Res>(refs: Refs | null, value: Req, callback: (error: string | null, response: Res | null) => void): void => {
    if (isClosed) return callback('The service is no longer running', null);
    let id = nextRequestID++;
    responseCallbacks.set(id, (error, response) => {
      try {
        callback(error, response as any);
      } finally {
        if (refs) refs.unref() // Do this after the callback so the callback can extend the lifetime if needed
      }
    });
    if (refs) refs.ref()
    streamIn.writeToStdin(protocol.encodePacket({ id, isRequest: true, value: value as any }));
  };

  let sendResponse = (id: number, value: protocol.Value): void => {
    if (isClosed) throw new Error('The service is no longer running');
    streamIn.writeToStdin(protocol.encodePacket({ id, isRequest: false, value }));
  };

  type RequestType =
    | protocol.PingRequest
    | protocol.OnResolveRequest
    | protocol.OnLoadRequest
    | protocol.OnRequestRequest
    | protocol.OnWaitRequest
    | protocol.OnWatchRebuildRequest

  let handleRequest = async (id: number, request: RequestType) => {
    // Catch exceptions in the code below so they get passed to the caller
    try {
      switch (request.command) {
        case 'ping': {
          sendResponse(id, {});
          break;
        }

        case 'resolve': {
          let callback = pluginCallbacks.get(request.key);
          sendResponse(id, await callback!(request) as any);
          break;
        }

        case 'load': {
          let callback = pluginCallbacks.get(request.key);
          sendResponse(id, await callback!(request) as any);
          break;
        }

        case 'serve-request': {
          let callbacks = serveCallbacks.get(request.serveID);
          if (callbacks && callbacks.onRequest) callbacks.onRequest(request.args);
          sendResponse(id, {});
          break;
        }

        case 'serve-wait': {
          let callbacks = serveCallbacks.get(request.serveID);
          if (callbacks) callbacks.onWait(request.error);
          sendResponse(id, {});
          break;
        }

        case 'watch-rebuild': {
          let callback = watchCallbacks.get(request.watchID);
          try {
            if (callback) callback(null, request.args);
          } catch (err) {
            console.error(err);
          }
          sendResponse(id, {});
          break;
        }

        default:
          throw new Error(`Invalid command: ` + (request as any)!.command);
      }
    } catch (e) {
      sendResponse(id, { errors: [extractErrorMessageV8(e, streamIn, null)] } as any);
    }
  };

  let isFirstPacket = true;

  let handleIncomingPacket = (bytes: Uint8Array): void => {
    // The first packet is a version check
    if (isFirstPacket) {
      isFirstPacket = false;

      // Validate the binary's version number to make sure esbuild was installed
      // correctly. This check was added because some people have reported
      // errors that appear to indicate an incorrect installation.
      let binaryVersion = String.fromCharCode(...bytes);
      if (binaryVersion !== ESBUILD_VERSION) {
        throw new Error(`Cannot start service: Host version "${ESBUILD_VERSION}" does not match binary version ${JSON.stringify(binaryVersion)}`);
      }
      return;
    }

    let packet = protocol.decodePacket(bytes) as any;

    if (packet.isRequest) {
      handleRequest(packet.id, packet.value);
    }

    else {
      let callback = responseCallbacks.get(packet.id)!;
      responseCallbacks.delete(packet.id);
      if (packet.value.error) callback(packet.value.error, {});
      else callback(null, packet.value);
    }
  };

  let handlePlugins = (plugins: types.Plugin[], request: protocol.BuildRequest, buildKey: number, stash: ObjectStash): Refs => {
    if (streamIn.isSync) throw new Error('Cannot use plugins in synchronous API calls');

    let onResolveCallbacks: {
      [id: number]: {
        name: string,
        callback: (args: types.OnResolveArgs) =>
          (types.OnResolveResult | null | undefined | Promise<types.OnResolveResult | null | undefined>),
      },
    } = {};
    let onLoadCallbacks: {
      [id: number]: {
        name: string,
        callback: (args: types.OnLoadArgs) =>
          (types.OnLoadResult | null | undefined | Promise<types.OnLoadResult | null | undefined>),
      },
    } = {};
    let nextCallbackID = 0;
    let i = 0;

    request.plugins = [];

    for (let item of plugins) {
      let keys: OptionKeys = {};
      if (typeof item !== 'object') throw new Error(`Plugin at index ${i} must be an object`);
      let name = getFlag(item, keys, 'name', mustBeString);
      let setup = getFlag(item, keys, 'setup', mustBeFunction);

      if (typeof name !== 'string' || name === '') throw new Error(`Plugin at index ${i} is missing a name`);
      if (typeof setup !== 'function') throw new Error(`[${name}] Plugin is missing a setup function`);
      checkForInvalidFlags(item, keys, `on plugin ${JSON.stringify(name)}`);

      let plugin: protocol.BuildPlugin = {
        name,
        onResolve: [],
        onLoad: [],
      };
      i++;

      setup({
        onResolve(options, callback) {
          let keys: OptionKeys = {};
          let filter = getFlag(options, keys, 'filter', mustBeRegExp);
          let namespace = getFlag(options, keys, 'namespace', mustBeString);
          checkForInvalidFlags(options, keys, `in onResolve() call for plugin ${JSON.stringify(name)}`);
          if (filter == null) throw new Error(`[${plugin.name}] onResolve() call is missing a filter`);
          let id = nextCallbackID++;
          onResolveCallbacks[id] = { name: name!, callback };
          plugin.onResolve.push({ id, filter: filter.source, namespace: namespace || '' });
        },

        onLoad(options, callback) {
          let keys: OptionKeys = {};
          let filter = getFlag(options, keys, 'filter', mustBeRegExp);
          let namespace = getFlag(options, keys, 'namespace', mustBeString);
          checkForInvalidFlags(options, keys, `in onLoad() call for plugin ${JSON.stringify(name)}`);
          if (filter == null) throw new Error(`[${plugin.name}] onLoad() call is missing a filter`);
          let id = nextCallbackID++;
          onLoadCallbacks[id] = { name: name!, callback };
          plugin.onLoad.push({ id, filter: filter.source, namespace: namespace || '' });
        },
      });

      request.plugins.push(plugin);
    }

    const callback: PluginCallback = async (request) => {
      switch (request.command) {
        case 'resolve': {
          let response: protocol.OnResolveResponse = {};
          for (let id of request.ids) {
            try {
              let { name, callback } = onResolveCallbacks[id];
              let result = await callback({
                path: request.path,
                importer: request.importer,
                namespace: request.namespace,
                resolveDir: request.resolveDir,
                pluginData: stash.load(request.pluginData),
              });

              if (result != null) {
                if (typeof result !== 'object') throw new Error(`Expected onResolve() callback in plugin ${JSON.stringify(name)} to return an object`);
                let keys: OptionKeys = {};
                let pluginName = getFlag(result, keys, 'pluginName', mustBeString);
                let path = getFlag(result, keys, 'path', mustBeString);
                let namespace = getFlag(result, keys, 'namespace', mustBeString);
                let external = getFlag(result, keys, 'external', mustBeBoolean);
                let pluginData = getFlag(result, keys, 'pluginData', canBeAnything);
                let errors = getFlag(result, keys, 'errors', mustBeArray);
                let warnings = getFlag(result, keys, 'warnings', mustBeArray);
                checkForInvalidFlags(result, keys, `from onResolve() callback in plugin ${JSON.stringify(name)}`);

                response.id = id;
                if (pluginName != null) response.pluginName = pluginName;
                if (path != null) response.path = path;
                if (namespace != null) response.namespace = namespace;
                if (external != null) response.external = external;
                if (pluginData != null) response.pluginData = stash.store(pluginData);
                if (errors != null) response.errors = sanitizeMessages(errors, 'errors', stash);
                if (warnings != null) response.warnings = sanitizeMessages(warnings, 'warnings', stash);
                break;
              }
            } catch (e) {
              return { id, errors: [extractErrorMessageV8(e, streamIn, stash)] };
            }
          }
          return response;
        }

        case 'load': {
          let response: protocol.OnLoadResponse = {};
          for (let id of request.ids) {
            try {
              let { name, callback } = onLoadCallbacks[id];
              let result = await callback({
                path: request.path,
                namespace: request.namespace,
                pluginData: stash.load(request.pluginData),
              });

              if (result != null) {
                if (typeof result !== 'object') throw new Error(`Expected onLoad() callback in plugin ${JSON.stringify(name)} to return an object`);
                let keys: OptionKeys = {};
                let pluginName = getFlag(result, keys, 'pluginName', mustBeString);
                let contents = getFlag(result, keys, 'contents', mustBeStringOrUint8Array);
                let resolveDir = getFlag(result, keys, 'resolveDir', mustBeString);
                let pluginData = getFlag(result, keys, 'pluginData', canBeAnything);
                let loader = getFlag(result, keys, 'loader', mustBeString);
                let errors = getFlag(result, keys, 'errors', mustBeArray);
                let warnings = getFlag(result, keys, 'warnings', mustBeArray);
                checkForInvalidFlags(result, keys, `from onLoad() callback in plugin ${JSON.stringify(name)}`);

                response.id = id;
                if (pluginName != null) response.pluginName = pluginName;
                if (contents instanceof Uint8Array) response.contents = contents;
                else if (contents != null) response.contents = protocol.encodeUTF8(contents);
                if (resolveDir != null) response.resolveDir = resolveDir;
                if (pluginData != null) response.pluginData = stash.store(pluginData);
                if (loader != null) response.loader = loader;
                if (errors != null) response.errors = sanitizeMessages(errors, 'errors', stash);
                if (warnings != null) response.warnings = sanitizeMessages(warnings, 'warnings', stash);
                break;
              }
            } catch (e) {
              return { id, errors: [extractErrorMessageV8(e, streamIn, stash)] };
            }
          }
          return response;
        }

        default:
          throw new Error(`Invalid command: ` + (request as any).command);
      }
    }

    let refCount = 0;
    return {
      ref() { if (++refCount === 1) pluginCallbacks.set(buildKey, callback); },
      unref() { if (--refCount === 0) pluginCallbacks.delete(buildKey) },
    };
  };

  interface ServeData {
    wait: Promise<void>
    stop: () => void
  }

  let buildServeData = (refs: Refs | null, options: types.ServeOptions, request: protocol.BuildRequest): ServeData => {
    let keys: OptionKeys = {};
    let port = getFlag(options, keys, 'port', mustBeInteger);
    let host = getFlag(options, keys, 'host', mustBeString);
    let onRequest = getFlag(options, keys, 'onRequest', mustBeFunction);
    let serveID = nextServeID++;
    let onWait: ServeCallbacks['onWait'];
    let wait = new Promise<void>((resolve, reject) => {
      onWait = error => {
        serveCallbacks.delete(serveID);
        if (error !== null) reject(new Error(error));
        else resolve();
      };
    });
    request.serve = { serveID };
    checkForInvalidFlags(options, keys, `in serve() call`);
    if (port !== void 0) request.serve.port = port;
    if (host !== void 0) request.serve.host = host;
    serveCallbacks.set(serveID, {
      onRequest,
      onWait: onWait!,
    });
    return {
      wait,
      stop() {
        sendRequest<protocol.ServeStopRequest, null>(refs, { command: 'serve-stop', serveID }, () => {
          // We don't care about the result
        });
      },
    };
  };

  return {
    readFromStdout,
    afterClose,

    service: {
      buildOrServe(callName, callerRefs, serveOptions, options, isTTY, defaultWD, callback) {
        let pluginRefs: Refs | undefined;
        const details = createObjectStash();
        const logLevelDefault = 'info';
        const refs = {
          ref() {
            if (pluginRefs) pluginRefs.ref()
            if (callerRefs) callerRefs.ref()
          },
          unref() {
            if (pluginRefs) pluginRefs.unref()
            if (callerRefs) callerRefs.unref()
          },
        }
        try {
          let key = nextBuildKey++;
          let writeDefault = !streamIn.isBrowser;
          let {
            flags,
            write,
            plugins,
            stdinContents,
            stdinResolveDir,
            absWorkingDir,
            incremental,
            watch,
          } = flagsForBuildOptions(callName, options, isTTY, logLevelDefault, writeDefault);
          let request: protocol.BuildRequest = {
            command: 'build',
            key,
            flags,
            write,
            stdinContents,
            stdinResolveDir,
            absWorkingDir: absWorkingDir || defaultWD,
            incremental,
            hasOnRebuild: !!(watch && watch.onRebuild),
          };
          let serve = serveOptions && buildServeData(refs, serveOptions, request);
          if (plugins && plugins.length > 0) pluginRefs = handlePlugins(plugins, request, key, details);

          // Factor out response handling so it can be reused for rebuilds
          let rebuild: types.BuildResult['rebuild'] | undefined;
          let stop: types.BuildResult['stop'] | undefined;
          let buildResponseToResult = (
            response: protocol.BuildResponse | null,
            callback: (error: Error | null, result: types.BuildResult | null) => void,
          ): void => {
            let errors = replaceDetailsInMessages(response!.errors, details);
            let warnings = replaceDetailsInMessages(response!.warnings, details);
            if (errors.length > 0) return callback(failureErrorWithLog('Build failed', errors, warnings), null);
            let result: types.BuildResult = { warnings };
            if (response!.outputFiles) result.outputFiles = response!.outputFiles.map(convertOutputFiles);

            // Handle incremental rebuilds
            if (response!.rebuildID !== void 0) {
              if (!rebuild) {
                let isDisposed = false;
                (rebuild as any) = () => new Promise<types.BuildResult>((resolve, reject) => {
                  if (isDisposed || isClosed) throw new Error('Cannot rebuild');
                  sendRequest<protocol.RebuildRequest, protocol.BuildResponse>(refs, { command: 'rebuild', rebuildID: response!.rebuildID! },
                    (error2, response2) => {
                      if (error2) return callback(new Error(error2), null);
                      buildResponseToResult(response2, (error3, result3) => {
                        if (error3) reject(error3);
                        else resolve(result3!);
                      });
                    });
                });
                refs.ref()
                rebuild!.dispose = () => {
                  if (isDisposed) return;
                  isDisposed = true;
                  sendRequest<protocol.RebuildDisposeRequest, null>(refs, { command: 'rebuild-dispose', rebuildID: response!.rebuildID! }, () => {
                    // We don't care about the result
                  });
                  refs.unref() // Do this after the callback so "sendRequest" can extend the lifetime
                };
              }
              result.rebuild = rebuild;
            }

            // Handle watch mode
            if (response!.watchID !== void 0) {
              if (!stop) {
                let isStopped = false;
                refs.ref()
                stop = () => {
                  if (isStopped) return;
                  isStopped = true;
                  watchCallbacks.delete(response!.watchID!);
                  sendRequest<protocol.WatchStopRequest, null>(refs, { command: 'watch-stop', watchID: response!.watchID! }, () => {
                    // We don't care about the result
                  });
                  refs.unref() // Do this after the callback so "sendRequest" can extend the lifetime
                }
                if (watch && watch.onRebuild) {
                  watchCallbacks.set(response!.watchID, (serviceStopError, watchResponse) => {
                    if (serviceStopError) return watch!.onRebuild!(serviceStopError as any, null);
                    let errors = replaceDetailsInMessages(watchResponse.errors, details);
                    let warnings = replaceDetailsInMessages(watchResponse.warnings, details);
                    if (errors.length > 0) return watch!.onRebuild!(failureErrorWithLog('Build failed', errors, warnings), null);
                    let result: types.BuildResult = { warnings };
                    if (watchResponse.outputFiles) result.outputFiles = watchResponse.outputFiles.map(convertOutputFiles);
                    if (watchResponse.rebuildID !== void 0) result.rebuild = rebuild;
                    result.stop = stop;
                    watch!.onRebuild!(null, result);
                  });
                }
              }
              result.stop = stop;
            }

            return callback(null, result);
          };

          if (write && streamIn.isBrowser) throw new Error(`Cannot enable "write" in the browser`);
          if (incremental && streamIn.isSync) throw new Error(`Cannot use "incremental" with a synchronous build`);
          sendRequest<protocol.BuildRequest, protocol.BuildResponse>(refs, request, (error, response) => {
            if (error) return callback(new Error(error), null);
            if (serve) {
              let serveResponse = response as any as protocol.ServeResponse;
              let isStopped = false

              // Add a ref/unref for "stop()"
              refs.ref()
              let result: types.ServeResult = {
                port: serveResponse.port,
                host: serveResponse.host,
                wait: serve.wait,
                stop() {
                  if (isStopped) return
                  isStopped = true
                  serve!.stop();
                  refs.unref() // Do this after the callback so "stop" can extend the lifetime
                },
              };

              // Add a ref/unref for "wait". This must be done independently of
              // "stop()" in case the response to "stop()" comes in first before
              // the request for "wait". Without this ref/unref, node may close
              // the child's stdin pipe after the "stop()" but before the "wait"
              // which will cause things to break. This caused a test failure.
              refs.ref()
              serve.wait.then(refs.unref, refs.unref)

              return callback(null, result);
            }
            return buildResponseToResult(response!, callback);
          });
        } catch (e) {
          let flags: string[] = [];
          try { pushLogFlags(flags, options, {}, isTTY, logLevelDefault) } catch { }
          sendRequest(refs, { command: 'error', flags, error: extractErrorMessageV8(e, streamIn, details) }, () => {
            callback(e, null);
          });
        }
      },

      transform(callName, refs, input, options, isTTY, fs, callback) {
        const details = createObjectStash();
        const logLevelDefault = 'silent';

        // Ideally the "transform()" API would be faster than calling "build()"
        // since it doesn't need to touch the file system. However, performance
        // measurements with large files on macOS indicate that sending the data
        // over the stdio pipe can be 2x slower than just using a temporary file.
        //
        // This appears to be an OS limitation. Both the JavaScript and Go code
        // are using large buffers but the pipe only writes data in 8kb chunks.
        // An investigation seems to indicate that this number is hard-coded into
        // the OS source code. Presumably files are faster because the OS uses
        // a larger chunk size, or maybe even reads everything in one syscall.
        //
        // The cross-over size where this starts to be faster is around 1mb on
        // my machine. In that case, this code tries to use a temporary file if
        // possible but falls back to sending the data over the stdio pipe if
        // that doesn't work.
        let start = (inputPath: string | null) => {
          try {
            let flags = flagsForTransformOptions(callName, options, isTTY, logLevelDefault);
            let request: protocol.TransformRequest = {
              command: 'transform',
              flags,
              inputFS: inputPath !== null,
              input: inputPath !== null ? inputPath : input,
            };
            sendRequest<protocol.TransformRequest, protocol.TransformResponse>(refs, request, (error, response) => {
              if (error) return callback(new Error(error), null);
              let errors = replaceDetailsInMessages(response!.errors, details);
              let warnings = replaceDetailsInMessages(response!.warnings, details);
              let outstanding = 1;
              let next = () => --outstanding === 0 && callback(null, { warnings, code: response!.code, map: response!.map });
              if (errors.length > 0) return callback(failureErrorWithLog('Transform failed', errors, warnings), null);

              // Read the JavaScript file from the file system
              if (response!.codeFS) {
                outstanding++;
                fs.readFile(response!.code, (err, contents) => {
                  if (err !== null) {
                    callback(err, null);
                  } else {
                    response!.code = contents!;
                    next();
                  }
                });
              }

              // Read the source map file from the file system
              if (response!.mapFS) {
                outstanding++;
                fs.readFile(response!.map, (err, contents) => {
                  if (err !== null) {
                    callback(err, null);
                  } else {
                    response!.map = contents!;
                    next();
                  }
                });
              }

              next();
            });
          } catch (e) {
            let flags: string[] = [];
            try { pushLogFlags(flags, options, {}, isTTY, logLevelDefault) } catch { }
            sendRequest(refs, { command: 'error', flags, error: extractErrorMessageV8(e, streamIn, details) }, () => {
              callback(e, null);
            });
          }
        };
        if (input.length > 1024 * 1024) {
          let next = start;
          start = () => fs.writeFile(input, next);
        }
        start(null);
      },
    },
  };
}

// This stores JavaScript objects on the JavaScript side and temporarily
// substitutes them with an integer that can be passed through the Go side
// and back. That way we can associate JavaScript objects with Go objects
// even if the JavaScript objects aren't serializable. And we also avoid
// the overhead of serializing large JavaScript objects.
interface ObjectStash {
  load(id: number): any;
  store(value: any): number;
}

function createObjectStash(): ObjectStash {
  const map = new Map<number, any>();
  let nextID = 0;
  return {
    load(id) {
      return map.get(id);
    },
    store(value) {
      if (value === void 0) return -1;
      const id = nextID++;
      map.set(id, value);
      return id;
    },
  };
}

function extractErrorMessageV8(e: any, streamIn: StreamIn, stash: ObjectStash | null): types.Message {
  let text = 'Internal error'
  let location: types.Location | null = null

  try {
    text = ((e && e.message) || e) + '';
  } catch {
  }

  // Optionally attempt to extract the file from the stack trace, works in V8/node
  try {
    let stack = e.stack + ''
    let lines = stack.split('\n', 3)
    let at = '    at '

    // Check to see if this looks like a V8 stack trace
    if (streamIn.readFileSync && !lines[0].startsWith(at) && lines[1].startsWith(at)) {
      let line = lines[1].slice(at.length)
      while (true) {
        // Unwrap a function name
        let match = /^\S+ \((.*)\)$/.exec(line)
        if (match) {
          line = match[1]
          continue
        }

        // Unwrap an eval wrapper
        match = /^eval at \S+ \((.*)\)(?:, \S+:\d+:\d+)?$/.exec(line)
        if (match) {
          line = match[1]
          continue
        }

        // Match on the file location
        match = /^(\S+):(\d+):(\d+)$/.exec(line)
        if (match) {
          let contents = streamIn.readFileSync(match[1], 'utf8')
          let lineText = contents.split(/\r\n|\r|\n|\u2028|\u2029/)[+match[2] - 1] || ''
          location = {
            file: match[1],
            namespace: 'file',
            line: +match[2],
            column: +match[3] - 1,
            length: 0,
            lineText: lineText + '\n' + lines.slice(1).join('\n'),
          }
        }
        break
      }
    }
  } catch {
  }

  return { text, location, detail: stash ? stash.store(e) : -1 }
}

function failureErrorWithLog(text: string, errors: types.Message[], warnings: types.Message[]): types.BuildFailure {
  let limit = 5
  let summary = errors.length < 1 ? '' : ` with ${errors.length} error${errors.length < 2 ? '' : 's'}:` +
    errors.slice(0, limit + 1).map((e, i) => {
      if (i === limit) return '\n...';
      if (!e.location) return `\nerror: ${e.text}`;
      let { file, line, column } = e.location;
      return `\n${file}:${line}:${column}: error: ${e.text}`;
    }).join('');
  let error: any = new Error(`${text}${summary}`);
  error.errors = errors;
  error.warnings = warnings;
  return error;
}

function replaceDetailsInMessages(messages: types.Message[], stash: ObjectStash): types.Message[] {
  for (const message of messages) {
    message.detail = stash.load(message.detail);
  }
  return messages;
}

function sanitizeMessages(messages: types.PartialMessage[], property: string, stash: ObjectStash): types.Message[] {
  let messagesClone: types.Message[] = [];
  let index = 0;

  for (const message of messages) {
    let keys: OptionKeys = {};
    let text = getFlag(message, keys, 'text', mustBeString);
    let location = getFlag(message, keys, 'location', mustBeObjectOrNull);
    let detail = getFlag(message, keys, 'detail', canBeAnything);
    checkForInvalidFlags(message, keys, `in element ${index} of "${property}"`);

    let locationClone: types.Message['location'] = null;
    if (location != null) {
      let keys: OptionKeys = {};
      let file = getFlag(location, keys, 'file', mustBeString);
      let namespace = getFlag(location, keys, 'namespace', mustBeString);
      let line = getFlag(location, keys, 'line', mustBeInteger);
      let column = getFlag(location, keys, 'column', mustBeInteger);
      let length = getFlag(location, keys, 'length', mustBeInteger);
      let lineText = getFlag(location, keys, 'lineText', mustBeString);
      checkForInvalidFlags(location, keys, `in element ${index} of "${property}"`);

      locationClone = {
        file: file || '',
        namespace: namespace || '',
        line: line || 0,
        column: column || 0,
        length: length || 0,
        lineText: lineText || '',
      };
    }

    messagesClone.push({
      text: text || '',
      location: locationClone,
      detail: stash.store(detail),
    });
    index++;
  }

  return messagesClone;
}

function convertOutputFiles({ path, contents }: protocol.BuildOutputFile): types.OutputFile {
  let text: string | null = null;
  return {
    path,
    contents,
    get text() {
      if (text === null) text = protocol.decodeUTF8(contents);
      return text;
    },
  }
}

// This function serves two purposes:
//
//   a) Only create one long-lived service for each unique value of "options".
//      This is useful because creating a service is expensive and it's
//      sometimes convenient for multiple independent libraries to create
//      esbuild services without coordinating with each other. This pooling
//      optimization makes this use case efficient.
//
//   b) Set the default working directory to the value of the current working
//      directory at the time "startService()" was called. This means each call
//      to "startService()" can potentially have a different default working
//      directory.
//
//      TODO: This is legacy behavior that originated because "startService()"
//      used to correspond to creating a new child process. That is no longer
//      the case because child processes are now pooled. This behavior is
//      being preserved for compatibility with Snowpack for now. I would like
//      to remove this strange behavior in a future release now that we have
//      the "absWorkingDir" API option.
//
export function longLivedService(getwd: () => string, startService: typeof types.startService): typeof types.startService {
  let entries = new Map<string, Promise<types.Service>>();
  return async (options) => {
    let cwd = getwd();
    let optionsJSON = JSON.stringify(options || {});
    let key = optionsJSON;
    let entry = entries.get(key);

    if (entry === void 0) {
      // Store the promise used to create the service so that multiple
      // concurrent calls to "startService()" will share the same promise.
      entry = startService(JSON.parse(optionsJSON));
      entries.set(key, entry);
    }

    try {
      let service = await entry;
      return {
        build: (options: any = {}): any => {
          if (cwd) {
            let absWorkingDir = options.absWorkingDir
            if (!absWorkingDir) options = { ...options, absWorkingDir: cwd }
          }
          return service.build(options);
        },
        serve(serveOptions, buildOptions: any = {}) {
          if (cwd) {
            let absWorkingDir = buildOptions.absWorkingDir
            if (!absWorkingDir) buildOptions = { ...buildOptions, absWorkingDir: cwd }
          }
          return service.serve(serveOptions, buildOptions);
        },
        transform(input, options) {
          return service.transform(input, options);
        },
        stop() {
          // This is now a no-op
        },
      };
    }

    catch (e) {
      // Remove the entry if loading fails, which allows
      // us to try again (only happens in the browser)
      entries.delete(key);
      throw e;
    }
  };
}
