import { resolveFetch } from './helper'
import {
  Fetch,
  FunctionInvokeOptions,
  FunctionRegion,
  FunctionsFetchError,
  FunctionsHttpError,
  FunctionsRelayError,
  FunctionsResponse,
} from './types'

/**
 * Client for invoking Supabase Edge Functions.
 */
export class FunctionsClient {
  protected url: string
  protected headers: Record<string, string>
  protected region: FunctionRegion
  protected fetch: Fetch

  /**
   * Creates a new Functions client bound to an Edge Functions URL.
   *
   * @example Using supabase-js (recommended)
   * ```ts
   * import { createClient } from '@supabase/supabase-js'
   *
   * const supabase = createClient('https://xyzcompany.supabase.co', 'your-publishable-key')
   * const { data, error } = await supabase.functions.invoke('hello-world')
   * ```
   *
   * @category Edge Functions
   *
   * @example Standalone import for bundle-sensitive environments
   * ```ts
   * import { FunctionsClient, FunctionRegion } from '@supabase/functions-js'
   *
   * const functions = new FunctionsClient('https://xyzcompany.supabase.co/functions/v1', {
   *   headers: { apikey: 'your-publishable-key' },
   *   region: FunctionRegion.UsEast1,
   * })
   * ```
   */
  constructor(
    url: string,
    {
      headers = {},
      customFetch,
      region = FunctionRegion.Any,
    }: {
      headers?: Record<string, string>
      customFetch?: Fetch
      region?: FunctionRegion
    } = {}
  ) {
    this.url = url
    this.headers = headers
    this.region = region
    this.fetch = resolveFetch(customFetch)
  }

  /**
   * Updates the authorization header
   * @param token - the new jwt token sent in the authorisation header
   *
   * @category Edge Functions
   *
   * @example Setting the authorization header
   * ```ts
   * functions.setAuth(session.access_token)
   * ```
   */
  setAuth(token: string) {
    this.headers.Authorization = `Bearer ${token}`
  }

  /**
   * Invokes a function
   * @param functionName - The name of the Function to invoke.
   * @param options - Options for invoking the Function.
   * @example
   * ```ts
   * const { data, error } = await functions.invoke('hello-world', {
   *   body: { name: 'Ada' },
   * })
   * ```
   *
   * @category Edge Functions
   *
   * @remarks
   * - The API key is sent in the `apikey` header. The `Authorization` header is reserved
   *   for the signed-in user's JWT (or a custom auth token) — when there is no session, a
   *   new-format API key (`sb_publishable_…` / `sb_secret_…`) is not sent as a Bearer token.
   * - Invoke params generally match the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) spec.
   * - When you pass in a body to your function, we automatically attach the Content-Type header for `Blob`, `ArrayBuffer`, `File`, `FormData` and `String`. If it doesn't match any of these types we assume the payload is `json`, serialize it and attach the `Content-Type` header as `application/json`. You can override this behavior by passing in a `Content-Type` header of your own.
   * - Responses are automatically parsed as `json`, `blob` and `form-data` depending on the `Content-Type` header sent by your function. Responses are parsed as `text` by default.
   *
   * @example Basic invocation
   * ```js
   * const { data, error } = await supabase.functions.invoke('hello', {
   *   body: { foo: 'bar' }
   * })
   * ```
   *
   * @exampleDescription Error handling
   * A `FunctionsHttpError` error is returned if your function throws an error, `FunctionsRelayError` if the Supabase Relay has an error processing your function and `FunctionsFetchError` if there is a network error in calling your function. Log the full error object so fields like `name`, `context`, and any structured body aren't hidden.
   *
   * @example Error handling
   * ```js
   * import { FunctionsHttpError, FunctionsRelayError, FunctionsFetchError } from "@supabase/supabase-js";
   *
   * const { data, error } = await supabase.functions.invoke('hello', {
   *   headers: {
   *     "my-custom-header": 'my-custom-header-value'
   *   },
   *   body: { foo: 'bar' }
   * })
   *
   * if (error instanceof FunctionsHttpError) {
   *   const errorMessage = await error.context.json()
   *   console.error('Function returned an error', errorMessage)
   * } else if (error instanceof FunctionsRelayError) {
   *   console.error('Relay error:', error)
   * } else if (error instanceof FunctionsFetchError) {
   *   console.error('Fetch error:', error)
   * }
   * ```
   *
   * @exampleDescription Passing custom headers
   * You can pass custom headers to your function. Note: supabase-js automatically passes the `Authorization` header with the signed in user's JWT.
   *
   * @example Passing custom headers
   * ```js
   * const { data, error } = await supabase.functions.invoke('hello', {
   *   headers: {
   *     "my-custom-header": 'my-custom-header-value'
   *   },
   *   body: { foo: 'bar' }
   * })
   * ```
   *
   * @exampleDescription Calling with DELETE HTTP verb
   * You can also set the HTTP verb to `DELETE` when calling your Edge Function.
   *
   * @example Calling with DELETE HTTP verb
   * ```js
   * const { data, error } = await supabase.functions.invoke('hello', {
   *   headers: {
   *     "my-custom-header": 'my-custom-header-value'
   *   },
   *   body: { foo: 'bar' },
   *   method: 'DELETE'
   * })
   * ```
   *
   * @exampleDescription Invoking a Function in the UsEast1 region
   * Here are the available regions:
   * - `FunctionRegion.Any`
   * - `FunctionRegion.ApNortheast1`
   * - `FunctionRegion.ApNortheast2`
   * - `FunctionRegion.ApSouth1`
   * - `FunctionRegion.ApSoutheast1`
   * - `FunctionRegion.ApSoutheast2`
   * - `FunctionRegion.CaCentral1`
   * - `FunctionRegion.EuCentral1`
   * - `FunctionRegion.EuWest1`
   * - `FunctionRegion.EuWest2`
   * - `FunctionRegion.EuWest3`
   * - `FunctionRegion.SaEast1`
   * - `FunctionRegion.UsEast1`
   * - `FunctionRegion.UsWest1`
   * - `FunctionRegion.UsWest2`
   *
   * @example Invoking a Function in the UsEast1 region
   * ```js
   * import { createClient, FunctionRegion } from '@supabase/supabase-js'
   *
   * const { data, error } = await supabase.functions.invoke('hello', {
   *   body: { foo: 'bar' },
   *   region: FunctionRegion.UsEast1
   * })
   * ```
   *
   * @exampleDescription Calling with GET HTTP verb
   * You can also set the HTTP verb to `GET` when calling your Edge Function.
   *
   * @example Calling with GET HTTP verb
   * ```js
   * const { data, error } = await supabase.functions.invoke('hello', {
   *   headers: {
   *     "my-custom-header": 'my-custom-header-value'
   *   },
   *   method: 'GET'
   * })
   * ```
   *
   * @example Standalone client invoke
   * ```ts
   * const { data, error } = await functions.invoke('hello-world', {
   *   body: { name: 'Ada' },
   * })
   * ```
   */
  async invoke<T = any>(
    functionName: string,
    options: FunctionInvokeOptions = {}
  ): Promise<FunctionsResponse<T>> {
    let timeoutId: ReturnType<typeof setTimeout> | undefined
    let timeoutController: AbortController | undefined
    let onAbort: (() => void) | undefined

    try {
      const { headers, method, body: functionArgs, signal, timeout } = options
      let _headers: Record<string, string> = {}
      let { region } = options
      if (!region) {
        region = this.region
      }
      // Add region as query parameter using URL API
      const url = new URL(`${this.url}/${functionName}`)
      if (region && region !== 'any') {
        _headers['x-region'] = region
        url.searchParams.set('forceFunctionRegion', region)
      }
      let body: any
      // HTTP header names are case-insensitive, so detect a caller-supplied Content-Type
      // regardless of casing — otherwise the SDK injects a second, conflicting Content-Type.
      const hasContentTypeHeader =
        !!headers && Object.keys(headers).some((key) => key.toLowerCase() === 'content-type')
      if (functionArgs && !hasContentTypeHeader) {
        if (
          (typeof Blob !== 'undefined' && functionArgs instanceof Blob) ||
          functionArgs instanceof ArrayBuffer
        ) {
          // will work for File as File inherits Blob
          // also works for ArrayBuffer as it is the same underlying structure as a Blob
          _headers['Content-Type'] = 'application/octet-stream'
          body = functionArgs
        } else if (typeof functionArgs === 'string') {
          // plain string
          _headers['Content-Type'] = 'text/plain'
          body = functionArgs
        } else if (typeof FormData !== 'undefined' && functionArgs instanceof FormData) {
          // don't set content-type headers
          // Request will automatically add the right boundary value
          body = functionArgs
        } else {
          // default, assume this is JSON
          _headers['Content-Type'] = 'application/json'
          body = JSON.stringify(functionArgs)
        }
      } else {
        if (
          functionArgs &&
          typeof functionArgs !== 'string' &&
          !(typeof Blob !== 'undefined' && functionArgs instanceof Blob) &&
          !(functionArgs instanceof ArrayBuffer) &&
          !(typeof FormData !== 'undefined' && functionArgs instanceof FormData)
        ) {
          body = JSON.stringify(functionArgs)
        } else {
          body = functionArgs
        }
      }

      // Handle timeout by creating an AbortController
      let effectiveSignal = signal
      if (timeout) {
        timeoutController = new AbortController()
        timeoutId = setTimeout(() => timeoutController!.abort(), timeout)

        // If user provided their own signal, we need to respect both
        if (signal) {
          effectiveSignal = timeoutController.signal
          // If the user's signal is aborted, abort our timeout controller too.
          // Store the listener so we can clean it up in finally.
          onAbort = () => timeoutController!.abort()
          signal.addEventListener('abort', onAbort)
        } else {
          effectiveSignal = timeoutController.signal
        }
      }

      const response = await this.fetch(url.toString(), {
        method: method || 'POST',
        // headers priority is (high to low):
        // 1. invoke-level headers
        // 2. client-level headers
        // 3. default Content-Type header
        headers: { ..._headers, ...this.headers, ...headers },
        body,
        signal: effectiveSignal,
      }).catch((fetchError) => {
        throw new FunctionsFetchError(fetchError)
      })

      const isRelayError = response.headers.get('x-relay-error')
      if (isRelayError && isRelayError === 'true') {
        throw new FunctionsRelayError(response)
      }

      if (!response.ok) {
        throw new FunctionsHttpError(response)
      }

      // HTTP media types are case-insensitive (RFC 9110), so normalize before
      // matching — otherwise an "Application/JSON" response falls through to text.
      let responseType = (response.headers.get('Content-Type') ?? 'text/plain')
        .split(';')[0]
        .trim()
        .toLowerCase()
      let data: any
      if (responseType === 'application/json') {
        data = await response.json()
      } else if (
        responseType === 'application/octet-stream' ||
        responseType === 'application/pdf'
      ) {
        data = await response.blob()
      } else if (responseType === 'text/event-stream') {
        data = response
      } else if (responseType === 'multipart/form-data') {
        data = await response.formData()
      } else {
        // default to text
        data = await response.text()
      }

      return { data, error: null, response }
    } catch (error) {
      return {
        data: null,
        error,
        response:
          error instanceof FunctionsHttpError || error instanceof FunctionsRelayError
            ? error.context
            : undefined,
      }
    } finally {
      // Clear the timeout if it was set
      if (timeoutId) {
        clearTimeout(timeoutId)
      }
      // Remove the cross-signal listener to prevent memory leaks when the caller
      // reuses the same AbortSignal across multiple invocations.
      if (onAbort) {
        options.signal?.removeEventListener('abort', onAbort)
      }
    }
  }
}
