/* eslint-disable @typescript-eslint/no-explicit-any */
import {processOptions} from './middleware/defaultOptionsProcessor'
import {validateOptions} from './middleware/defaultOptionsValidator'
import type {
  HttpContext,
  HttpRequest,
  HttpRequestOngoing,
  Middleware,
  MiddlewareChannels,
  MiddlewareHooks,
  MiddlewareReducer,
  MiddlewareResponse,
  Middlewares,
  Requester,
  RequestOptions,
} from './types'
import {middlewareReducer} from './util/middlewareReducer'
import {createPubSub} from './util/pubsub'

const channelNames = [
  'request',
  'response',
  'progress',
  'error',
  'abort',
] satisfies (keyof MiddlewareChannels)[]
const middlehooks = [
  'processOptions',
  'validateOptions',
  'interceptRequest',
  'finalizeOptions',
  'onRequest',
  'onResponse',
  'onError',
  'onReturn',
  'onHeaders',
] satisfies (keyof MiddlewareHooks)[]

/** @public */
export function createRequester(initMiddleware: Middlewares, httpRequest: HttpRequest): Requester {
  const loadedMiddleware: Middlewares = []
  const middleware: MiddlewareReducer = middlehooks.reduce(
    (ware, name) => {
      ware[name] = ware[name] || []
      return ware
    },
    {
      processOptions: [processOptions],
      validateOptions: [validateOptions],
    } as any,
  )

  function request(opts: RequestOptions | string) {
    // Or pass in an external error (ex. from @sanity/client) to use its stack instead of ours
    const externalCallSiteError =
      typeof opts === 'object' && opts.callSiteStack ? opts.callSiteStack : undefined
    // Capture an error now before async operations happen, but defer stack formatting until an error is actually thrown
    const ownError = externalCallSiteError ? undefined : new Error()

    const onResponse = (reqErr: Error | null, res: MiddlewareResponse, ctx: HttpContext) => {
      let error = reqErr
      let response: MiddlewareResponse | null = res

      // We're processing non-errors first, in case a middleware converts the
      // response into an error (for instance, status >= 400 == HttpError)
      if (!error) {
        try {
          response = applyMiddleware('onResponse', res, ctx)
        } catch (err: any) {
          response = null
          error = err
        }
      }

      // Apply error middleware - if middleware return the same (or a different) error,
      // publish as an error event. If we *don't* return an error, assume it has been handled
      error = error && applyMiddleware('onError', error, ctx)

      // Figure out if we should publish on error/response channels
      if (error) {
        if (error instanceof Error) {
          // Append the call-site frames so errors can be traced back to the code that initiated the request, not just the internal pipeline.
          // Prefer an externally provided stack (ex. @sanity/client) over the one captured here inside get-it's request() function.
          const stack = externalCallSiteError?.stack || ownError?.stack
          if (typeof stack === 'string') {
            // External stacks come from the actual user call site, so only drop the "Error" header.
            // Internal stacks also include this request() frame, so drop that as well.
            const callSiteLines = stack.split('\n').slice(externalCallSiteError ? 1 : 2)
            if (callSiteLines.length > 0) {
              error.stack += '\n' + callSiteLines.join('\n')
            }
          }
        }
        channels.error.publish(error)
      } else if (response) {
        channels.response.publish(response)
      }
    }

    const channels: MiddlewareChannels = channelNames.reduce((target, name) => {
      target[name] = createPubSub() as MiddlewareChannels[typeof name]
      return target
    }, {} as any)

    // Prepare a middleware reducer that can be reused throughout the lifecycle
    const applyMiddleware = middlewareReducer(middleware)

    // Parse the passed options
    const options = applyMiddleware('processOptions', opts as RequestOptions)

    // Validate the options
    applyMiddleware('validateOptions', options)

    // Build a context object we can pass to child handlers
    const context = {options, channels, applyMiddleware}

    // We need to hold a reference to the current, ongoing request,
    // in order to allow cancellation. In the case of the retry middleware,
    // a new request might be triggered
    let ongoingRequest: HttpRequestOngoing | undefined
    const unsubscribe = channels.request.subscribe((ctx) => {
      // Let request adapters (node/browser) perform the actual request
      ongoingRequest = httpRequest(ctx, (err, res) => onResponse(err, res!, ctx))
    })

    // If we abort the request, prevent further requests from happening,
    // and be sure to cancel any ongoing request (obviously)
    channels.abort.subscribe(() => {
      unsubscribe()
      if (ongoingRequest) {
        ongoingRequest.abort()
      }
    })

    // See if any middleware wants to modify the return value - for instance
    // the promise or observable middlewares
    const returnValue = applyMiddleware('onReturn', channels, context)

    // If return value has been modified by a middleware, we expect the middleware
    // to publish on the 'request' channel. If it hasn't been modified, we want to
    // trigger it right away
    if (returnValue === channels) {
      channels.request.publish(context)
    }

    return returnValue
  }

  request.use = function use(newMiddleware: Middleware) {
    if (!newMiddleware) {
      throw new Error('Tried to add middleware that resolved to falsey value')
    }

    if (typeof newMiddleware === 'function') {
      throw new Error(
        'Tried to add middleware that was a function. It probably expects you to pass options to it.',
      )
    }

    if (newMiddleware.onReturn && middleware.onReturn.length > 0) {
      throw new Error(
        'Tried to add new middleware with `onReturn` handler, but another handler has already been registered for this event',
      )
    }

    middlehooks.forEach((key) => {
      if (newMiddleware[key]) {
        middleware[key].push(newMiddleware[key] as any)
      }
    })

    loadedMiddleware.push(newMiddleware)
    return request
  }

  request.clone = () => createRequester(loadedMiddleware, httpRequest)

  initMiddleware.forEach(request.use)

  return request
}
