// import { relative, resolve } from "path";
import type { PluginBuild } from "esbuild"
import type * as babel from "@babel/core"
import type { NodePath, types } from "@babel/core"
import { expression } from "@babel/template"
import hash from "@emotion/hash"
import { compile, serialize, stringify } from "stylis"
import { dropRightWhile } from "lodash"

const NS = "extract-css"

const runtimeArgs = ["Tag", "className", "vars"] as const
// const root = resolve(__dirname, "../..");

const shared = /* jsx */ `
  ({ as = Tag, className: cls, style, ...rest }, ref) => {
    return _jsx(as, {
      ...rest,
      ref,
      style: vars != null ? getStyle({ ...style }, className, vars) : style,
      className: cx([className, cls])
    })
  }
`

const runtime = /* jsx */ `
  import { jsx } from "react/jsx-runtime";
  import { forwardRef, memo } from "react";
  import { cx } from "@emotion/css";

  const supportsAs = new WeakSet();
  const _jsx = jsx;

  function getStyle(style, className, vars) {
    for (let i = 0; i < vars.length; i++) {
      const name = "--" + className + "-" + i;
      const variable = vars[i];
      if (variable !== null) {
        style[name] = typeof variable === "function" ? variable(props) : variable;
      }
    }
    return style;
  }

  export default function create(${runtimeArgs.join(", ")}) {
    const Component = memo(
      forwardRef(
        supportsAs.has(Tag)
          ? ${shared.replace("as, {", "Tag, { as,")}
          : ${shared}
      )
    );

    Component.withComponent = function (Tag) {
      return create(${runtimeArgs.join(", ")});
    };

    supportsAs.add(Component);

    return Component;
  }
`

const flattenArgs = (args: { [key in (typeof runtimeArgs)[number]]: string }) =>
  runtimeArgs.map(key => args[key]).join(", ")

const styledComponent = expression({
  // plugins: ["jsx"],
  syntacticPlaceholders: true,
})(
  "%%create%%(" +
    flattenArgs({
      className: "%%className%%",
      Tag: "%%tag%%",
      vars: "%%vars%%",
    }) +
    ")",
)

const validParentTypes = new Set<types.Node["type"]>([
  "ArrayExpression",
  "BinaryExpression",
  "CallExpression",
  "JSXExpressionContainer",
  "LogicalExpression",
  "ObjectProperty",
  "VariableDeclarator",
])

interface State extends babel.PluginPass {
  styles: string[]
  program: types.Program
  runtimeHelper?: types.Identifier
}

export function extractCSS(build: PluginBuild, { className }: { className: string }) {
  const RUNTIME = /^@extract-css\/runtime$/

  build.onResolve({ filter: RUNTIME }, ({ path }) => ({ namespace: NS, path }))

  build.onLoad({ filter: RUNTIME, namespace: NS }, () => ({
    contents: runtime,
    loader: "jsx",
    resolveDir: __dirname,
  }))

  const plugin = ({ types: t }: typeof babel): babel.PluginObj<State> => {
    const classNameMap = new WeakMap<types.Expression, string>()

    function isImportedSpecifierFrom(path: NodePath, name: string, module: string) {
      const declPath = path.scope.getBinding(name)!.path
      return (
        t.isImportSpecifier(declPath.node) &&
        t.isImportDeclaration(declPath.parent) &&
        declPath.parent.source.value === module
      )
    }

    function isDefaultImportedFrom(path: NodePath, name: string, module: string) {
      const binding = path.scope.getBinding(name)
      if (!binding) return false
      const declPath = binding.path
      return (
        t.isImportDefaultSpecifier(declPath.node) &&
        t.isImportDeclaration(declPath.parent) &&
        declPath.parent.source.value === module
      )
    }

    function buildClassName(hash: string, componentName = "styled") {
      return className.replaceAll("[hash]", hash).replaceAll("[name]", componentName)
    }

    function getComponentName(path: NodePath): string | undefined {
      if (t.isVariableDeclarator(path.parent) && t.isIdentifier(path.parent.id)) {
        return path.parent.id.name
      }
    }

    const visitor: babel.Visitor<State> = {
      TaggedTemplateExpression(path, state) {
        const { styles } = state
        const quasiExps = path
          .get("quasi")
          .get("expressions") as NodePath<types.Expression>[]

        const { tag, quasi } = path.node

        if (!validParentTypes.has(path.parent.type)) {
          return
        }

        function extract(getPrefix: (className: string) => string) {
          let bailout = false
          const raws = quasi.quasis.map(q => q.value.raw)
          const className = buildClassName(
            hash(JSON.stringify(raws)),
            getComponentName(path),
          )
          const skipInterpolations = new Set<number>()

          const cssText = raws
            .map((left, i) => {
              if (bailout) {
                return null!
              }

              if (i === raws.length - 1) {
                return left
              }

              const exp = quasi.expressions[i]
              const evaluated = quasiExps[i].evaluate()
              if (evaluated.confident) {
                if (
                  typeof evaluated.value === "string" ||
                  typeof evaluated.value === "number"
                ) {
                  skipInterpolations.add(i)
                  return left + String(evaluated.value)
                }
              }

              if (t.isIdentifier(exp)) {
                const binding = path.scope.getBinding(exp.name)
                if (binding) {
                  const { node } = binding.path
                  if (t.isVariableDeclarator(node)) {
                    const cls = classNameMap.get(node.init!)
                    if (cls) {
                      skipInterpolations.add(i)
                      return left + cls
                    }
                    if (t.isStringLiteral(node.init)) {
                      skipInterpolations.add(i)
                      return left + node.init.value
                    }
                  }

                  bailout = true
                }
              } else if (t.isStringLiteral(exp)) {
                skipInterpolations.add(i)
                return left + exp.value
              }

              return `${left}var(--${className}-${i})`
            })
            .join("")

          const compiled = compile(`${getPrefix(className)}{${cssText}}}`)

          return {
            className,
            skipInterpolations,
            bailout,
            style: serialize(compiled, stringify),
          }
        }

        function processStyled(tag: types.Expression) {
          const { className, skipInterpolations, style, bailout } = extract(c => `.${c}`)
          if (bailout) {
            return
          }

          styles.push(style)

          if (!state.runtimeHelper) {
            state.runtimeHelper = path.scope.generateUidIdentifier("create")
            state.program.body.unshift(
              t.importDeclaration(
                [t.importDefaultSpecifier(state.runtimeHelper)],
                t.stringLiteral("@extract-css/runtime"),
              ),
            )
          }

          const fn = styledComponent({
            create: state.runtimeHelper,
            className: t.stringLiteral(className),
            tag:
              t.isIdentifier(tag) && /^[a-z]/.test(tag.name)
                ? t.stringLiteral(tag.name)
                : tag,
            vars: quasi.expressions.length
              ? t.arrayExpression(
                  dropRightWhile(
                    quasi.expressions.map((e, i) =>
                      skipInterpolations.has(i)
                        ? t.nullLiteral()
                        : (e as types.Expression),
                    ),
                    n => t.isNullLiteral(n),
                  ),
                )
              : t.nullLiteral(),
          })
          classNameMap.set(fn, className)
          const [newPath] = path.replaceWith(fn)
          newPath.addComment("leading", " @__PURE__ ")
        }

        if (t.isIdentifier(tag) && tag.name === "keyframes") {
          if (!isImportedSpecifierFrom(path, tag.name, "@emotion/css")) {
            return
          }

          const { className, style, bailout } = extract(c => `@keyframes ${c}`)
          if (bailout) {
            return
          }
          styles.push(style)
          path.replaceWith(t.stringLiteral(className))
        } else if (t.isIdentifier(tag) && tag.name === "css") {
          if (!isImportedSpecifierFrom(path, tag.name, "@emotion/css")) {
            return
          }

          const { className, style, bailout } = extract(c => `.${c}`)
          if (bailout) {
            return
          }
          styles.push(style)
          path.replaceWith(t.stringLiteral(className))
        } else if (
          t.isMemberExpression(tag) &&
          t.isIdentifier(tag.object, { name: "styled" }) &&
          !t.isPrivateName(tag.property)
        ) {
          if (!isDefaultImportedFrom(path, tag.object.name, "@emotion/styled")) {
            return
          }
          processStyled(tag.property)
        } else if (
          t.isCallExpression(tag) &&
          t.isIdentifier(tag.callee, { name: "styled" }) &&
          tag.arguments.length === 1 &&
          t.isExpression(tag.arguments[0])
        ) {
          if (!isDefaultImportedFrom(path, tag.callee.name, "@emotion/styled")) {
            return
          }
          processStyled(tag.arguments[0])
        }
      },
    }

    return {
      name: "plugin",
      visitor: {
        Program: {
          enter(program, state) {
            state.styles = []
            state.program = program.node
          },
          exit(program, state) {
            program.traverse(visitor, state)

            if (state.styles.length) {
              const css =
                // `/*./${relative(root, state.filename!)}*/` +
                state.styles.join("\n")
              program.node.body.unshift(
                t.importDeclaration(
                  [],
                  t.stringLiteral(`data:text/css,${encodeURI(css)}`),
                ),
              )
            }
          },
        },
      },
    }
  }

  return plugin
}
