#!/usr/bin/env -S node -r esbin
import "dotenv/config"
import { promises as fs } from "fs"
import { resolve } from "path"
import glob from "fast-glob"
import { program } from "commander"
import sass from "esbuild-plugin-sass"
import * as esbuild from "esbuild"
import type { PluginItem } from "@babel/core"
import { c } from "./utils"
import { cssModules } from "./plugins/esbuild-css-modules"
import { babelPlugin as babel } from "./plugins/esbuild-babel"
import { externalDep } from "./plugins/esbuild-external-dep"
import { stringImport } from "./plugins/esbuild-string-import"
import { yamlPlugin as yaml } from "./plugins/esbuild-yaml"
import { alias } from "./plugins/esbuild-alias"
import { getDefaultTarget } from "./plugins/esbuild-browserslist"

const dist = "dist"
const SERVE_PORT = 3114

program
  .option("-g, --grep <pattern>", "only build packages that match <pattern>")
  .option("-s, --silent", "hide all output")
  .option(
    "-e, --env <env>",
    "set the NODE_ENV environment variable",
    process.env.NODE_ENV || "production",
  )
  .option("-w, --watch", "watch for changes and rebuild")
  .parse()

const { grep, silent, env, watch } = program.opts()
const __PROD__ = env === "production"

const log = silent ? () => {} : console.log

async function main() {
  const queue = new Queue([
    new Build("src/options/index.tsx", `${dist}/app/options`, { serve: true }),
    new Build("src/popup/index.tsx", `${dist}/app/popup`),
    new Build("src/server/index.ts", `${dist}/app/server`, {
      platform: "node",
      format: "cjs",
      splitting: false,
      minify: false,
    }),
    new Build("src/background/index.ts", `${dist}/app/background`),
    new Build("src/background/compiler.ts", `${dist}/app/background/compiler`),
    new Build("src/context/index.ts", `${dist}/app/context`, {
      format: "iife",
      splitting: false,
    }),
    new Build("src/devtools/index.ts", `${dist}/app/devtools`),
    new Build("src/install/index.ts", `${dist}/app/install`),
    new Build("src/graphiql/index.tsx", `${dist}/app/playground`),
    new Build("src/reference/index.tsx", `${dist}/app/reference`, {
      format: "iife",
      splitting: false,
    }),
  ]).filter(grep ? task => task.entry.includes(grep) : () => true)

  if (!queue.length) {
    return
  }

  await queue.run(task => task.clean())

  log(
    watch ? "Watching" : "Building",
    `${c.blue(queue.length)} package(s) in ${c.blue(env)}`,
  )
  if (queue.length < 5) {
    log(queue.tasks.map((t, i) => ` ${i + 1}. ${c.green(t.entry)}`).join("\n"))
  }

  await queue.build()

  if (__PROD__) {
    await printBundleSize()
  }
}

if (require.main === module) {
  Promise.resolve()
    .then(() => main())
    .catch(e => {
      console.error(e)
      process.exit(1)
    })
}

type BuildOptions = Pick<
  esbuild.BuildOptions,
  "entryPoints" | "outdir" | "format" | "splitting" | "platform" | "minify"
> & {
  serve?: boolean
}

export const getBasicOptions = ({
  entryPoints,
  minify = __PROD__,
  plugins = [],
  babelPlugins = [],
}: {
  entryPoints: [string]
  minify?: boolean
  plugins?: esbuild.Plugin[]
  babelPlugins?: PluginItem[]
}): Partial<esbuild.BuildOptions> => ({
  entryPoints,
  define: {
    ...Object.entries(process.env).reduce(
      (acc, [key, value]) =>
        key.startsWith("NEXT_PUBLIC_")
          ? { ...acc, [`process.env.${key}`]: JSON.stringify(value) }
          : acc,
      {} as Record<string, string>,
    ),
    "process.env.NODE_ENV": JSON.stringify(env),
    "process.env.NODE_DEBUG": "false",
    "process.env.SERVE_PORT": String(SERVE_PORT),
    "process.browser": "true",
    global: "globalThis",
  },
  bundle: true,
  format: "esm",
  external: ["path", "glob", "fs", "util", "monaco-editor"],
  plugins: [
    ...plugins,
    externalDep(["prettier", "sass"]),
    babel(babelPlugins),
    yaml(),
    stringImport(),
    alias({
      lodash: require.resolve("lodash-es"),
      "webextension-polyfill": require.resolve("../src/vendor/webextension-polyfill"),
      "monaco-editor": require.resolve("../src/options/monaco.ts"),

      // https://github.com/MichalLytek/type-graphql/issues/366#issuecomment-511075437
      "libphonenumber-js": require.resolve("lodash.noop"),
      // we never use graphql-subscriptions
      "graphql-subscriptions": require.resolve("lodash.noop"),

      // Make esbuild use the module version
      graphql: require.resolve("graphql/index.mjs"),

      ...(entryPoints[0].includes("graphiql") || __PROD__
        ? {}
        : {
            react: require.resolve("../src/vendor/why-did-you-render/index.js"),
            "react/jsx-runtime": require.resolve(
              "../src/vendor/why-did-you-render/jsx-runtime",
            ),
          }),
    }),
    cssModules({
      generateScopedName: __PROD__
        ? "[hash:base64:6]"
        : "[name]__[local]___[hash:base64:5]",
      localsConvention: "camelCaseOnly",
    }),
    sass(),
  ].filter(Boolean),
  target: getDefaultTarget(),
  banner: {
    js: "/* eslint-disable */",
  },
  legalComments: "none",
  keepNames: false,
  tsconfig: "./tsconfig.json",
  sourcemap: "linked",
  minify,
  splitting: true,
  metafile: true,
  loader: {
    ".eot": "file",
    ".png": "file",
    ".ttf": "file",
    ".woff": "file",
    ".woff2": "file",
  },
})

async function printBundleSize() {
  const root = resolve(dist, "app")
  const files = await glob(["**/*", "!**/*.map", "!**/meta.json"], {
    cwd: root,
    onlyFiles: true,
  })
  const sizes = await Promise.all(
    files.map(async file => {
      const { size } = await fs.stat(resolve(root, file))
      return { file, size }
    }),
  )

  const list = sizes
    .filter(a => a.size > 50000)
    .sort((a, b) => b.size - a.size)
    .slice(0, 10)

  for (const { file, size } of list) {
    log(
      c.blue(`./dist/app/${file.padEnd(38, " ")}`),
      c.green(`${humanFileSize(size).padStart(9, " ")}`),
    )
  }
}

function humanFileSize(bytes: number, si = false, dp = 1) {
  const thresh = si ? 1000 : 1024

  if (Math.abs(bytes) < thresh) {
    return bytes + " B"
  }

  const units = si
    ? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
    : ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
  let u = -1
  const r = 10 ** dp

  do {
    bytes /= thresh
    ++u
  } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1)

  return bytes.toFixed(dp) + " " + units[u]
}

class Build {
  enabled = true

  constructor(
    readonly entry: string,
    readonly outdir: string,
    readonly options?: BuildOptions,
  ) {}

  disable() {
    this.enabled = false
    return this
  }

  async clean() {
    if (!this.enabled) return

    await fs.rm(this.outdir, { recursive: true, force: true })
  }

  async build() {
    if (!this.enabled) return

    const { outdir, options: { serve, ...options } = {} } = this

    const ctx = await esbuild.context({
      ...getBasicOptions({
        entryPoints: [this.entry],
      }),
      ...options,
      outdir,
    })

    const { metafile } = await ctx.rebuild()

    if (__PROD__) {
      await fs.writeFile(resolve(outdir, "meta.json"), JSON.stringify(metafile))
    }

    if (watch) {
      await ctx.watch()
      if (serve) {
        await ctx.serve({ port: SERVE_PORT })
      }
    } else {
      await ctx.dispose()
    }
  }
}

class Queue {
  constructor(public tasks: readonly Build[]) {}

  get length() {
    return this.tasks.length
  }

  filter(predicate: (task: Build) => boolean) {
    return new Queue(this.tasks.filter(predicate))
  }

  run(fn: (task: Build) => Promise<void>) {
    return Promise.all(this.tasks.map(fn))
  }

  build() {
    return this.run(task => task.build())
  }
}
