import type { Context } from '@netlify/edge-functions'
import {
  HTMLRewriter,
  type TextChunk,
} from '../vendor/deno.land/x/htmlrewriter@v1.0.0/src/index.ts'

import { updateModifiedHeaders } from './headers.ts'
import type { StructuredLogger } from './logging.ts'
import {
  addMiddlewareHeaders,
  hasMiddlewareResponseHeadersToApply,
  isMiddlewareRequest,
  isMiddlewareResponse,
  mergeMiddlewareCookies,
} from './middleware.ts'
import { RequestData } from './next-request.ts'
import {
  addBasePath,
  normalizeDataUrl,
  normalizeLocalePath,
  normalizeTrailingSlash,
  relativizeURL,
  removeBasePath,
  rewriteDataPath,
} from './util.ts'

export interface FetchEventResult {
  response: Response
  waitUntil: Promise<any>
}

interface BuildResponseOptions {
  context: Context
  logger: StructuredLogger
  request: Request
  result: FetchEventResult
  nextConfig?: RequestData['nextConfig']
}

export const buildResponse = async ({
  context,
  logger,
  request,
  result,
  nextConfig,
}: BuildResponseOptions): Promise<Response | void> => {
  logger
    .withFields({ is_nextresponse_next: result.response.headers.has('x-middleware-next') })
    .debug('Building Next.js response')

  updateModifiedHeaders(request.headers, result.response.headers)

  // They've returned the MiddlewareRequest directly, so we'll call `next()` for them.
  if (isMiddlewareRequest(result.response)) {
    result.response = await result.response.next()
  }

  if (isMiddlewareResponse(result.response)) {
    const { response } = result
    if (request.method === 'HEAD' || request.method === 'OPTIONS') {
      return response.originResponse
    }

    // NextResponse doesn't set cookies onto the originResponse, so we need to copy them over
    // In some cases, it's possible there are no headers set. See https://github.com/netlify/pod-ecosystem-frameworks/issues/475
    if (response.cookies._headers?.has('set-cookie')) {
      response.originResponse.headers.set(
        'set-cookie',
        response.cookies._headers.get('set-cookie')!,
      )
    }

    // If it's JSON we don't need to use the rewriter, we can just parse it
    if (response.originResponse.headers.get('content-type')?.includes('application/json')) {
      const props = await response.originResponse.json()
      const transformed = response.dataTransforms.reduce((prev, transform) => {
        return transform(prev)
      }, props)
      const body = JSON.stringify(transformed)
      const headers = new Headers(response.headers)
      headers.set('content-length', String(body.length))

      return Response.json(transformed, { ...response, headers })
    }

    if (response.dataTransforms.length > 0 || response.elementHandlers.length > 0) {
      // Log when HTMLRewriter code path is triggered (controlled by NETLIFY_LOG_HTML_REWRITER env var at runtime)
      if (Deno.env.get('NETLIFY_LOG_HTML_REWRITER') === 'true') {
        logger
          .withFields({
            dataTransforms_count: response.dataTransforms.length,
            elementHandlers_count: response.elementHandlers.length,
          })
          .log('Using HTMLRewriter for response transformation')
      }

      const { initHtmlRewriter } = await import('../html-rewriter-wasm.ts')
      await initHtmlRewriter()

      // This var will hold the contents of the script tag
      let buffer = ''
      // Create an HTMLRewriter that matches the Next data script tag
      const rewriter = new HTMLRewriter()

      if (response.dataTransforms.length > 0) {
        rewriter.on('script[id="__NEXT_DATA__"]', {
          text(textChunk: TextChunk) {
            // Grab all the chunks in the Next data script tag
            buffer += textChunk.text
            if (textChunk.lastInTextNode) {
              try {
                // When we have all the data, try to parse it as JSON
                const data = JSON.parse(buffer.trim())
                // Apply all of the transforms to the props
                const props = response.dataTransforms.reduce(
                  (prev, transform) => transform(prev),
                  data.props,
                )
                // Replace the data with the transformed props
                // With `html: true` the input is treated as raw HTML
                // @see https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/#global-types
                textChunk.replace(JSON.stringify({ ...data, props }), { html: true })
              } catch (err) {
                console.log('Could not parse', err)
              }
            } else {
              // Remove the chunk after we've appended it to the buffer
              textChunk.remove()
            }
          },
        })
      }

      if (response.elementHandlers.length > 0) {
        response.elementHandlers.forEach(([selector, handlers]) => rewriter.on(selector, handlers))
      }
      return rewriter.transform(response.originResponse)
    } else {
      return response.originResponse
    }
  }

  let edgeResponse = new Response(result.response.body, result.response)
  request.headers.set('x-nf-next-middleware', 'skip')

  let rewrite = edgeResponse.headers.get('x-middleware-rewrite')
  let redirect = edgeResponse.headers.get('location')
  let nextRedirect = edgeResponse.headers.get('x-nextjs-redirect')

  // Data requests (i.e. requests for /_next/data ) need special handling
  const isDataReq = request.headers.has('x-nextjs-data')
  // Data requests need to be normalized to the route path
  if (isDataReq && !redirect && !rewrite && !nextRedirect) {
    const requestUrl = new URL(request.url)
    const normalizedDataUrl = normalizeDataUrl(requestUrl.pathname)
    // Don't rewrite unless the URL has changed
    if (normalizedDataUrl !== requestUrl.pathname) {
      rewrite = `${normalizedDataUrl}${requestUrl.search}`
      logger.withFields({ rewrite_url: rewrite }).debug('Rewritten data URL')
    }
  }

  if (rewrite) {
    logger.withFields({ rewrite_url: rewrite }).debug('Found middleware rewrite')

    const rewriteUrl = new URL(rewrite, request.url)
    const baseUrl = new URL(request.url)
    if (
      rewriteUrl.toString() === baseUrl.toString() &&
      !hasMiddlewareResponseHeadersToApply(edgeResponse, {
        ignoreHeaders: ['x-middleware-rewrite'],
      })
    ) {
      logger
        .withFields({ rewrite_url: rewrite })
        .debug('Rewrite URL is the same as original URL and no response headers need to be applied')
      return
    }

    const relativeUrl = relativizeURL(rewrite, request.url)

    if (isDataReq) {
      // Data requests might be rewritten to an external URL
      // This header tells the client router the redirect target, and if it's external then it will do a full navigation

      edgeResponse.headers.set('x-nextjs-rewrite', relativeUrl)
    }

    if (rewriteUrl.origin !== baseUrl.origin) {
      logger.withFields({ rewrite_url: rewrite }).debug('Rewriting to external url')
      const proxyRequest = await cloneRequest(rewriteUrl, request)

      // Remove Netlify internal headers
      for (const key of request.headers.keys()) {
        if (key.startsWith('x-nf-')) {
          proxyRequest.headers.delete(key)
        }
      }

      return addMiddlewareHeaders(fetch(proxyRequest, { redirect: 'manual' }), edgeResponse)
    }

    if (isDataReq) {
      rewriteUrl.pathname = rewriteDataPath({
        dataUrl: new URL(request.url).pathname,
        newRoute: removeBasePath(rewriteUrl.pathname, nextConfig?.basePath),
        basePath: nextConfig?.basePath,
      })
    } else {
      // respect trailing slash rules to prevent 308s
      rewriteUrl.pathname = normalizeTrailingSlash(rewriteUrl.pathname, nextConfig?.trailingSlash)
    }

    const target = normalizeLocalizedTarget({ target: rewriteUrl.toString(), request, nextConfig })
    if (
      target === request.url &&
      !hasMiddlewareResponseHeadersToApply(edgeResponse, {
        ignoreHeaders: ['x-middleware-rewrite'],
      })
    ) {
      logger
        .withFields({ rewrite_url: rewrite })
        .debug(
          'Normalized rewrite URL is the same as original URL and no response headers need to be applied',
        )
      return
    }
    edgeResponse.headers.set('x-middleware-rewrite', relativeUrl)
    request.headers.set('x-middleware-rewrite', target)

    // cookies set in middleware need to be available during the lambda request
    const newRequest = await cloneRequest(target, request)
    const newRequestCookies = mergeMiddlewareCookies(edgeResponse, newRequest)
    if (newRequestCookies) {
      newRequest.headers.set('Cookie', newRequestCookies)
    }

    return addMiddlewareHeaders(context.next(newRequest), edgeResponse)
  }

  if (redirect) {
    redirect = normalizeLocalizedTarget({ target: redirect, request, nextConfig })
    if (redirect === request.url) {
      if (hasMiddlewareResponseHeadersToApply(edgeResponse, { ignoreHeaders: ['location'] })) {
        // if we need to apply headers but the redirect is to the same URL, we should remove the location header and apply the other headers,
        // otherwise we might end up with a redirect loop in the browser with no way for the client to know that something has changed (e.g. cookies have been set)
        const headersWithoutLocation = new Headers(edgeResponse.headers)
        headersWithoutLocation.delete('location')
        headersWithoutLocation.set('x-middleware-next', '1')
        edgeResponse = new Response(null, {
          status: 200,
          headers: headersWithoutLocation,
        })
      } else {
        logger
          .withFields({ redirect_url: redirect })
          .debug(
            'Redirect url is the same as original URL and no response headers need to be applied',
          )
        return
      }
    }
    edgeResponse.headers.set('location', relativizeURL(redirect, request.url))
  }

  // Data requests shouldn't automatically redirect in the browser (they might be HTML pages): they're handled by the router
  if (redirect && isDataReq) {
    edgeResponse.headers.delete('location')
    edgeResponse.headers.set('x-nextjs-redirect', relativizeURL(redirect, request.url))
  }

  nextRedirect = edgeResponse.headers.get('x-nextjs-redirect')

  if (nextRedirect && isDataReq) {
    edgeResponse.headers.set('x-nextjs-redirect', normalizeDataUrl(nextRedirect))
  }

  if (edgeResponse.headers.get('x-middleware-next') === '1') {
    edgeResponse.headers.delete('x-middleware-next')

    // cookies set in middleware need to be available during the lambda request
    const newRequest = await cloneRequest(request.url, request)
    const newRequestCookies = mergeMiddlewareCookies(edgeResponse, newRequest)
    if (newRequestCookies) {
      newRequest.headers.set('Cookie', newRequestCookies)
    }

    return addMiddlewareHeaders(context.next(newRequest), edgeResponse)
  }

  return edgeResponse
}

/**
 * Normalizes the locale in a URL.
 */
function normalizeLocalizedTarget({
  target,
  request,
  nextConfig,
}: {
  target: string
  request: Request
  nextConfig?: RequestData['nextConfig']
}): string {
  const targetUrl = new URL(target, request.url)

  const normalizedTarget = normalizeLocalePath(targetUrl.pathname, nextConfig?.i18n?.locales)

  if (
    normalizedTarget.detectedLocale &&
    !normalizedTarget.pathname.startsWith(`/api/`) &&
    !normalizedTarget.pathname.startsWith(`/_next/static/`)
  ) {
    targetUrl.pathname =
      addBasePath(
        `/${normalizedTarget.detectedLocale}${normalizedTarget.pathname}`,
        nextConfig?.basePath,
      ) || `/`
  } else {
    targetUrl.pathname = addBasePath(normalizedTarget.pathname, nextConfig?.basePath) || `/`
  }
  return targetUrl.toString()
}

async function cloneRequest(url, request: Request) {
  // This is not ideal, but streaming to an external URL doesn't work
  const body = request.body && !request.bodyUsed ? await request.arrayBuffer() : undefined
  return new Request(url, {
    headers: request.headers,
    method: request.method,
    body,
  })
}
