import { promises as fs, existsSync } from 'fs'
import { resolve, join } from 'path'

import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build'
import { greenBright } from 'chalk'
import destr from 'destr'
import { copy, copyFile, emptyDir, ensureDir, readJson, writeJSON, writeJson } from 'fs-extra'
import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin'
import type { RouteHas } from 'next/dist/lib/load-custom-routes'
import { outdent } from 'outdent'

import { getRequiredServerFiles, NextConfig } from './config'
import { makeLocaleOptional, stripLookahead } from './matchers'
import { RoutesManifest } from './types'

// This is the format as of next@12.2
interface EdgeFunctionDefinitionV1 {
  env: string[]
  files: string[]
  name: string
  page: string
  regexp: string
}

interface AssetRef {
  name: string
  filePath: string
}
export interface MiddlewareMatcher {
  regexp: string
  locale?: false
  has?: RouteHas[]
}

// This is the format after next@12.3.0
interface EdgeFunctionDefinitionV2 {
  env: string[]
  files: string[]
  name: string
  page: string
  matchers: MiddlewareMatcher[]
  wasm?: AssetRef[]
  assets?: AssetRef[]
}

type EdgeFunctionDefinition = EdgeFunctionDefinitionV1 | EdgeFunctionDefinitionV2

export interface FunctionManifest {
  version: 1
  functions: Array<
    | {
        function: string
        name?: string
        path: string
        cache?: 'manual'
      }
    | {
        function: string
        name?: string
        pattern: string
        cache?: 'manual'
      }
  >
  import_map?: string
}

const maybeLoadJson = <T>(path: string): Promise<T> | null => {
  if (existsSync(path)) {
    return readJson(path)
  }
}

export const loadMiddlewareManifest = (netlifyConfig: NetlifyConfig): Promise<MiddlewareManifest | null> =>
  maybeLoadJson(resolve(netlifyConfig.build.publish, 'server', 'middleware-manifest.json'))

export const loadAppPathRoutesManifest = (netlifyConfig: NetlifyConfig): Promise<Record<string, string> | null> =>
  maybeLoadJson(resolve(netlifyConfig.build.publish, 'app-path-routes-manifest.json'))

/**
 * Convert the Next middleware name into a valid Edge Function name
 */
const sanitizeName = (name: string) => `next_${name.replace(/\W/g, '_')}`

/**
 * Initialization added to the top of the edge function bundle
 */
const preamble = /* js */ `
import {
  decode as _base64Decode,
} from "https://deno.land/std@0.159.0/encoding/base64.ts";
// Deno defines "window", but naughty libraries think this means it's a browser
delete globalThis.window
globalThis.process = { env: {...Deno.env.toObject(), NEXT_RUNTIME: 'edge', 'NEXT_PRIVATE_MINIMAL_MODE': '1' } }
globalThis.EdgeRuntime = "netlify-edge"
let _ENTRIES = {}

// Next.js uses this extension to the Headers API implemented by Cloudflare workerd
if(!('getAll' in Headers.prototype)) {
  Headers.prototype.getAll = function getAll(name) {
    name = name.toLowerCase();
    if (name !== "set-cookie") {
      throw new Error("Headers.getAll is only supported for Set-Cookie");
    }
    return [...this.entries()]
      .filter(([key]) => key === name)
      .map(([, value]) => value);
  };
}
//  Next uses blob: urls to refer to local assets, so we need to intercept these
const _fetch = globalThis.fetch
const fetch = async (url, init) => {
  try {
    if (typeof url === 'object' && url.href?.startsWith('blob:')) {
      const key = url.href.slice(5)
      if (key in _ASSETS) {
        return new Response(_base64Decode(_ASSETS[key]))
      }
    }
    return await _fetch(url, init)
  } catch (error) {
    console.error(error)
    throw error
  }
}

// Next edge runtime uses "self" as a function-scoped global-like object, but some of the older polyfills expect it to equal globalThis
// See https://nextjs.org/docs/basic-features/supported-browsers-features#polyfills
const self = { ...globalThis, fetch }

`

// Slightly different spacing in different versions!
const IMPORT_UNSUPPORTED = [
  `Object.defineProperty(globalThis,"__import_unsupported"`,
  `    Object.defineProperty(globalThis, "__import_unsupported"`,
]
/**
 * Concatenates the Next edge function code with the required chunks and adds an export
 */
const getMiddlewareBundle = async ({
  edgeFunctionDefinition,
  netlifyConfig,
}: {
  edgeFunctionDefinition: EdgeFunctionDefinition
  netlifyConfig: NetlifyConfig
}): Promise<string> => {
  const { publish } = netlifyConfig.build
  const chunks: Array<string> = [preamble]

  if ('wasm' in edgeFunctionDefinition) {
    for (const { name, filePath } of edgeFunctionDefinition.wasm) {
      const wasm = await fs.readFile(join(publish, filePath))
      chunks.push(`const ${name} = _base64Decode(${JSON.stringify(wasm.toString('base64'))}).buffer`)
    }
  }

  if ('assets' in edgeFunctionDefinition) {
    chunks.push(`const _ASSETS = {}`)
    for (const { name, filePath } of edgeFunctionDefinition.assets) {
      const wasm = await fs.readFile(join(publish, filePath))
      chunks.push(`_ASSETS[${JSON.stringify(name)}] = ${JSON.stringify(wasm.toString('base64'))}`)
    }
  }

  for (const file of edgeFunctionDefinition.files) {
    const filePath = join(publish, file)

    let data = await fs.readFile(filePath, 'utf8')
    // Next defines an immutable global variable, which is fine unless you have more than one in the bundle
    // This adds a check to see if the global is already defined
    data = IMPORT_UNSUPPORTED.reduce(
      (acc, val) => acc.replace(val, `('__import_unsupported' in globalThis)||${val}`),
      data,
    )
    chunks.push('{', data, '}')
  }

  const exports = /* js */ `export default _ENTRIES["middleware_${edgeFunctionDefinition.name}"].default;`
  chunks.push(exports)
  return chunks.join('\n')
}

const getEdgeTemplatePath = (file: string) => join(__dirname, '..', '..', 'src', 'templates', 'edge', file)

const copyEdgeSourceFile = ({
  file,
  target,
  edgeFunctionDir,
}: {
  file: string
  edgeFunctionDir: string
  target?: string
}) => fs.copyFile(getEdgeTemplatePath(file), join(edgeFunctionDir, target ?? file))

const writeEdgeFunction = async ({
  edgeFunctionDefinition,
  edgeFunctionRoot,
  netlifyConfig,
  pageRegexMap,
  appPathRoutesManifest = {},
  nextConfig,
  cache,
}: {
  edgeFunctionDefinition: EdgeFunctionDefinition
  edgeFunctionRoot: string
  netlifyConfig: NetlifyConfig
  pageRegexMap?: Map<string, string>
  appPathRoutesManifest?: Record<string, string>
  nextConfig: NextConfig
  cache?: 'manual'
}): Promise<
  Array<{
    function: string
    name: string
    pattern: string
  }>
> => {
  const name = sanitizeName(edgeFunctionDefinition.name)
  const edgeFunctionDir = join(edgeFunctionRoot, name)

  const bundle = await getMiddlewareBundle({
    edgeFunctionDefinition,
    netlifyConfig,
  })

  await ensureDir(edgeFunctionDir)
  await fs.writeFile(join(edgeFunctionDir, 'bundle.js'), bundle)

  await copyEdgeSourceFile({
    edgeFunctionDir,
    file: 'runtime.ts',
    target: 'index.ts',
  })

  const matchers: EdgeFunctionDefinitionV2['matchers'] = []

  // The v1 middleware manifest has a single regexp, but the v2 has an array of matchers
  if ('regexp' in edgeFunctionDefinition) {
    matchers.push({ regexp: edgeFunctionDefinition.regexp })
  } else if (nextConfig.i18n) {
    matchers.push(
      ...edgeFunctionDefinition.matchers.map((matcher) => ({
        ...matcher,
        regexp: makeLocaleOptional(matcher.regexp),
      })),
    )
  } else {
    matchers.push(...edgeFunctionDefinition.matchers)
  }

  // If the EF matches a page, it's an app dir page so needs a matcher too
  // The object will be empty if appDir isn't enabled in the Next config
  if (pageRegexMap && edgeFunctionDefinition.page in appPathRoutesManifest) {
    const regexp = pageRegexMap.get(appPathRoutesManifest[edgeFunctionDefinition.page])
    if (regexp) {
      matchers.push({ regexp })
    }
  }

  await writeJson(join(edgeFunctionDir, 'matchers.json'), matchers)

  // We add a defintion for each matching path
  return matchers.map((matcher) => {
    const pattern = stripLookahead(matcher.regexp)
    return { function: name, pattern, name: edgeFunctionDefinition.name, cache }
  })
}
export const cleanupEdgeFunctions = ({
  INTERNAL_EDGE_FUNCTIONS_SRC = '.netlify/edge-functions',
}: NetlifyPluginConstants) => emptyDir(INTERNAL_EDGE_FUNCTIONS_SRC)

export const writeDevEdgeFunction = async ({
  INTERNAL_EDGE_FUNCTIONS_SRC = '.netlify/edge-functions',
}: NetlifyPluginConstants) => {
  const manifest: FunctionManifest = {
    functions: [
      {
        function: 'next-dev',
        name: 'netlify dev handler',
        path: '/*',
      },
    ],
    version: 1,
  }
  const edgeFunctionRoot = resolve(INTERNAL_EDGE_FUNCTIONS_SRC)
  await emptyDir(edgeFunctionRoot)
  await writeJson(join(edgeFunctionRoot, 'manifest.json'), manifest)
  await copy(getEdgeTemplatePath('../edge-shared'), join(edgeFunctionRoot, 'edge-shared'))

  const edgeFunctionDir = join(edgeFunctionRoot, 'next-dev')
  await ensureDir(edgeFunctionDir)
  await copyEdgeSourceFile({ edgeFunctionDir, file: 'next-dev.js', target: 'index.js' })
}

/**
 * Writes Edge Functions for the Next middleware
 */
export const writeEdgeFunctions = async ({
  netlifyConfig,
  routesManifest,
}: {
  netlifyConfig: NetlifyConfig
  routesManifest: RoutesManifest
}) => {
  const manifest: FunctionManifest = {
    functions: [],
    version: 1,
  }

  const edgeFunctionRoot = resolve('.netlify', 'edge-functions')
  await emptyDir(edgeFunctionRoot)

  const { publish } = netlifyConfig.build
  const nextConfigFile = await getRequiredServerFiles(publish)
  const nextConfig = nextConfigFile.config
  const usesAppDir = nextConfig.experimental?.appDir
  await copy(getEdgeTemplatePath('../edge-shared'), join(edgeFunctionRoot, 'edge-shared'))
  await writeJSON(join(edgeFunctionRoot, 'edge-shared', 'nextConfig.json'), nextConfig)

  if (
    !destr(process.env.NEXT_DISABLE_EDGE_IMAGES) &&
    !destr(process.env.NEXT_DISABLE_NETLIFY_EDGE) &&
    !destr(process.env.DISABLE_IPX)
  ) {
    console.log(
      'Using Netlify Edge Functions for image format detection. Set env var "NEXT_DISABLE_EDGE_IMAGES=true" to disable.',
    )
    const edgeFunctionDir = join(edgeFunctionRoot, 'ipx')
    await ensureDir(edgeFunctionDir)
    await copyEdgeSourceFile({ edgeFunctionDir, file: 'ipx.ts', target: 'index.ts' })
    await copyFile(
      join('.netlify', 'functions-internal', '_ipx', 'imageconfig.json'),
      join(edgeFunctionDir, 'imageconfig.json'),
    )
    manifest.functions.push({
      function: 'ipx',
      name: 'next/image handler',
      path: '/_next/image*',
    })
  }
  if (!destr(process.env.NEXT_DISABLE_NETLIFY_EDGE)) {
    const middlewareManifest = await loadMiddlewareManifest(netlifyConfig)
    if (!middlewareManifest) {
      console.error("Couldn't find the middleware manifest")
      return
    }

    let usesEdge = false

    for (const middleware of middlewareManifest.sortedMiddleware) {
      usesEdge = true
      const edgeFunctionDefinition = middlewareManifest.middleware[middleware]
      const functionDefinitions = await writeEdgeFunction({
        edgeFunctionDefinition,
        edgeFunctionRoot,
        netlifyConfig,
        nextConfig,
      })
      manifest.functions.push(...functionDefinitions)
    }
    // Older versions of the manifest format don't have the functions field
    // No, the version field was not incremented
    if (typeof middlewareManifest.functions === 'object') {
      // When using the app dir, we also need to check if the EF matches a page
      const appPathRoutesManifest = await loadAppPathRoutesManifest(netlifyConfig)

      const pageRegexMap = new Map(
        [...(routesManifest.dynamicRoutes || []), ...(routesManifest.staticRoutes || [])].map((route) => [
          route.page,
          route.regex,
        ]),
      )

      for (const edgeFunctionDefinition of Object.values(middlewareManifest.functions)) {
        usesEdge = true
        const functionDefinitions = await writeEdgeFunction({
          edgeFunctionDefinition,
          edgeFunctionRoot,
          netlifyConfig,
          pageRegexMap,
          appPathRoutesManifest,
          nextConfig,
          // cache: "manual" is currently experimental, so we restrict it to sites that use experimental appDir
          cache: usesAppDir ? 'manual' : undefined,
        })
        manifest.functions.push(...functionDefinitions)
      }
    }
    if (usesEdge) {
      console.log(outdent`
        ✨ Deploying middleware and functions to ${greenBright`Netlify Edge Functions`} ✨
        This feature is in beta. Please share your feedback here: https://ntl.fyi/next-netlify-edge
      `)
    }
  }
  await writeJson(join(edgeFunctionRoot, 'manifest.json'), manifest)
}
