import {
  parse,
  type DocumentNode,
  type OperationDefinitionNode,
  type OperationTypeNode,
} from 'graphql'
import type { GraphQLVariables } from './graphql-handler'
import { toPublicUrl } from '#core/utils/request/to-public-url'
import { devUtils } from '#core/utils/internal/dev-utils'
import { jsonParse } from '#core/utils/internal/json-parse'
import { parseMultipartData } from '#core/utils/internal/parse-multipart-data'

interface GraphQLInput {
  query: string | null
  variables?: GraphQLVariables
  operationName?: string | null
}

export interface ParsedGraphQLQuery {
  /**
   * Undefined if the document has no operation matching the
   * requested operation name.
   */
  operationType?: OperationTypeNode
  operationName?: string
}

export type ParsedGraphQLRequest<
  VariablesType extends GraphQLVariables = GraphQLVariables,
> =
  | (ParsedGraphQLQuery & {
      query: string
      variables?: VariablesType
    })
  | undefined

export function parseDocumentNode(
  node: DocumentNode,
  operationName?: string | null,
): ParsedGraphQLQuery {
  const operationDefs = node.definitions.filter((definition) => {
    return definition.kind === 'OperationDefinition'
  }) as Array<OperationDefinitionNode>

  /**
   * @note A document may bundle multiple operations (e.g. the ones
   * emitted by GraphQL Code Generator). Honor the requested operation
   * name, and resolve nothing if the document has no such operation.
   * Falling back to the first operation would silently resolve one the
   * client never asked for.
   */
  const operationDef = operationName
    ? operationDefs.find((definition) => {
        return definition.name?.value === operationName
      })
    : operationDefs[0]

  return {
    operationType: operationDef?.operation,
    /**
     * @note Echo the requested operation name even when the document
     * has no such operation. It makes the "unhandled operation" warnings
     * name the operation the client actually asked for.
     */
    operationName: operationDef?.name?.value || operationName || undefined,
  }
}

async function parseQuery(
  query: string,
  operationName?: string | null,
): Promise<ParsedGraphQLQuery | Error> {
  try {
    const ast = parse(query)
    return parseDocumentNode(ast, operationName)
  } catch (error) {
    return error as Error
  }
}

type GraphQLParsedOperationsMap = Record<string, string[]>
export type GraphQLMultipartRequestBody = {
  operations: string
  map?: string
} & {
  [fileName: string]: File
}

function extractMultipartVariables<VariablesType extends GraphQLVariables>(
  variables: VariablesType,
  map: GraphQLParsedOperationsMap,
  files: Record<string, File>,
) {
  const operations = { variables }

  for (const [key, pathArray] of Object.entries(map)) {
    if (!(key in files)) {
      throw new Error(`Given files do not have a key '${key}' .`)
    }

    for (const dotPath of pathArray) {
      const [lastPath, ...reversedPaths] = dotPath.split('.').reverse()
      const paths = reversedPaths.reverse()
      let target: Record<string, any> = operations

      for (const path of paths) {
        if (!(path in target)) {
          throw new Error(`Property '${path}' is not in operations.`)
        }

        target = target[path]
      }

      target[lastPath] = files[key]
    }
  }

  return operations.variables
}

async function getGraphQLInput(request: Request): Promise<GraphQLInput | null> {
  switch (request.method) {
    case 'GET': {
      const url = new URL(request.url)
      const query = url.searchParams.get('query')
      const variables = url.searchParams.get('variables') || ''

      return {
        query,
        variables: jsonParse(variables),
        operationName: url.searchParams.get('operationName'),
      }
    }

    case 'POST': {
      // Clone the request so we could read its body without locking
      // the body stream to the downward consumers.
      const requestClone = request.clone()

      // Handle multipart body GraphQL operations.
      if (
        request.headers.get('content-type')?.includes('multipart/form-data')
      ) {
        const responseJson = parseMultipartData<GraphQLMultipartRequestBody>(
          await requestClone.text(),
          request.headers,
        )

        if (!responseJson) {
          return null
        }

        const { operations, map, ...files } = responseJson
        const parsedOperations =
          jsonParse<{
            query?: string
            variables?: GraphQLVariables
            operationName?: string | null
          }>(operations) || {}

        if (!parsedOperations.query) {
          return null
        }

        const parsedMap = jsonParse<GraphQLParsedOperationsMap>(map || '') || {}
        const variables = parsedOperations.variables
          ? extractMultipartVariables(
              parsedOperations.variables,
              parsedMap,
              files,
            )
          : {}

        return {
          query: parsedOperations.query,
          variables,
          operationName: parsedOperations.operationName,
        }
      }

      // Handle plain POST GraphQL operations.
      const requestJson: {
        query: string
        variables?: GraphQLVariables
        operationName?: string | null
        operations?: any /** @todo Annotate this */
      } = await requestClone.json().catch(() => null)

      if (requestJson?.query) {
        const { query, variables, operationName } = requestJson

        return {
          query,
          variables,
          operationName,
        }
      }
      return null
    }

    default:
      return null
  }
}

/**
 * Determines if a given request can be considered a GraphQL request.
 * Does not parse the query and does not guarantee its validity.
 */
export async function parseGraphQLRequest(
  request: Request,
): Promise<ParsedGraphQLRequest> {
  const input = await getGraphQLInput(request)

  if (!input || !input.query) {
    return
  }

  const { query, variables, operationName } = input
  const parsedResult = await parseQuery(query, operationName)

  if (parsedResult instanceof Error) {
    const requestPublicUrl = toPublicUrl(request.url)

    throw new Error(
      devUtils.formatMessage(
        'Failed to intercept a GraphQL request to "%s %s": cannot parse query. See the error message from the parser below.\n\n%s',
        request.method,
        requestPublicUrl,
        parsedResult.message,
      ),
    )
  }

  return {
    query: input.query,
    operationType: parsedResult.operationType,
    operationName: parsedResult.operationName,
    variables,
  }
}
