'use strict'

const util = require('../core/util')
const assert = require('node:assert')
const { InvalidArgumentError } = require('../core/errors')

const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]

const noop = () => {}

class RedirectHandler {
  static buildDispatch (dispatcher, maxRedirections) {
    if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) {
      throw new InvalidArgumentError('maxRedirections must be a positive number')
    }

    const dispatch = dispatcher.dispatch.bind(dispatcher)
    return (opts, originalHandler) => dispatch(opts, new RedirectHandler(dispatch, maxRedirections, opts, originalHandler))
  }

  constructor (dispatch, maxRedirections, opts, handler) {
    if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) {
      throw new InvalidArgumentError('maxRedirections must be a positive number')
    }

    if (opts.throwOnMaxRedirect != null && typeof opts.throwOnMaxRedirect !== 'boolean') {
      throw new InvalidArgumentError('throwOnMaxRedirect must be a boolean')
    }

    this.dispatch = dispatch
    this.location = null
    const { maxRedirections: _, stripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect, ...cleanOpts } = opts
    this.opts = cleanOpts // opts must be a copy, exclude maxRedirections
    this.opts.body = util.wrapRequestBody(this.opts.body)
    this.stripHeadersOnRedirect = normalizeStripHeaders(stripHeadersOnRedirect, 'stripHeadersOnRedirect')
    this.stripHeadersOnCrossOriginRedirect = normalizeStripHeaders(stripHeadersOnCrossOriginRedirect, 'stripHeadersOnCrossOriginRedirect')
    this.maxRedirections = maxRedirections
    this.handler = handler
    this.history = []
  }

  onRequestStart (controller, context) {
    this.handler.onRequestStart?.(controller, { ...context, history: this.history })
  }

  onRequestUpgrade (controller, statusCode, headers, socket) {
    this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
  }

  onResponseStart (controller, statusCode, headers, statusMessage) {
    if (this.opts.throwOnMaxRedirect && this.history.length >= this.maxRedirections) {
      throw new Error('max redirects')
    }

    let removeContentHeaders = statusCode === 303

    // https://tools.ietf.org/html/rfc7231#section-6.4.2
    // https://fetch.spec.whatwg.org/#http-redirect-fetch
    // In case of HTTP 301 or 302 with POST, change the method to GET
    // QUERY is safe (RFC 10008) and should not change method like GET.
    if ((statusCode === 301 || statusCode === 302) && this.opts.method === 'POST') {
      this.opts.method = 'GET'
      if (util.isStream(this.opts.body)) {
        util.destroy(this.opts.body.on('error', noop))
      }
      this.opts.body = null
      removeContentHeaders = true
    }

    // https://tools.ietf.org/html/rfc7231#section-6.4.4
    // In case of HTTP 303, always replace method to be either HEAD or GET
    if (statusCode === 303 && this.opts.method !== 'HEAD') {
      this.opts.method = 'GET'
      if (util.isStream(this.opts.body)) {
        util.destroy(this.opts.body.on('error', noop))
      }
      this.opts.body = null
    }

    this.location = this.history.length >= this.maxRedirections || util.isDisturbed(this.opts.body) || redirectableStatusCodes.indexOf(statusCode) === -1
      ? null
      : headers.location

    if (this.opts.origin) {
      this.history.push(new URL(this.opts.path, this.opts.origin))
    }

    if (!this.location) {
      this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
      return
    }

    const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin)))
    const path = search ? `${pathname}${search}` : pathname

    // Check for redirect loops by seeing if we've already visited this URL in our history
    // This catches the case where Client/Pool try to handle cross-origin redirects but fail
    // and keep redirecting to the same URL in an infinite loop
    const redirectUrlString = `${origin}${path}`
    for (const historyUrl of this.history) {
      if (historyUrl.toString() === redirectUrlString) {
        throw new InvalidArgumentError(`Redirect loop detected. Cannot redirect to ${origin}. This typically happens when using a Client or Pool with cross-origin redirects. Use an Agent for cross-origin redirects.`)
      }
    }

    // Remove headers referring to the original URL.
    // By default it is Host only. A 303 or a 301/302 POST-to-GET redirect also removes all Content-* headers.
    // https://tools.ietf.org/html/rfc7231#section-6.4
    this.opts.headers = cleanRequestHeaders(this.opts.headers, removeContentHeaders, this.opts.origin !== origin, this.stripHeadersOnRedirect, this.stripHeadersOnCrossOriginRedirect)
    this.opts.path = path
    this.opts.origin = origin
    this.opts.query = null
  }

  onResponseData (controller, chunk) {
    if (this.location) {
      /*
        https://tools.ietf.org/html/rfc7231#section-6.4

        TLDR: undici always ignores 3xx response bodies.

        Redirection is used to serve the requested resource from another URL, so it assumes that
        no body is generated (and thus can be ignored). Even though generating a body is not prohibited.

        For status 301, 302, 303, 307 and 308 (the latter from RFC 7238), the specs mention that the body usually
        (which means it's optional and not mandated) contain just an hyperlink to the value of
        the Location response header, so the body can be ignored safely.

        For status 300, which is "Multiple Choices", the spec mentions both generating a Location
        response header AND a response body with the other possible location to follow.
        Since the spec explicitly chooses not to specify a format for such body and leave it to
        servers and browsers implementors, we ignore the body as there is no specified way to eventually parse it.
      */
    } else {
      this.handler.onResponseData?.(controller, chunk)
    }
  }

  onResponseEnd (controller, trailers) {
    if (this.location) {
      /*
        https://tools.ietf.org/html/rfc7231#section-6.4

        TLDR: undici always ignores 3xx response trailers as they are not expected in case of redirections
        and neither are useful if present.

        See comment on onData method above for more detailed information.
      */
      this.dispatch(this.opts, this)
    } else {
      this.handler.onResponseEnd(controller, trailers)
    }
  }

  onResponseError (controller, error) {
    this.handler.onResponseError?.(controller, error)
  }
}

// https://tools.ietf.org/html/rfc7231#section-6.4.4
function shouldRemoveHeader (header, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
  const name = util.headerNameToString(header)
  if (name === 'host') {
    return true
  }
  if (stripHeaders?.has(name) || (unknownOrigin && stripHeadersOnCrossOrigin?.has(name))) {
    return true
  }
  if (removeContent && name.startsWith('content-')) {
    return true
  }
  if (unknownOrigin) {
    return name === 'authorization' || name === 'cookie' || name === 'proxy-authorization'
  }
  return false
}

// https://tools.ietf.org/html/rfc7231#section-6.4
function normalizeStripHeaders (headers, optionName) {
  if (headers == null) {
    return null
  }

  if (!Array.isArray(headers)) {
    throw new InvalidArgumentError(`${optionName} must be an array`)
  }

  const normalized = new Set()
  for (const header of headers) {
    if (typeof header !== 'string') {
      throw new InvalidArgumentError(`${optionName} must contain header names`)
    }

    normalized.add(util.headerNameToString(header))
  }
  return normalized
}

function cleanRequestHeaders (headers, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
  const ret = []
  if (Array.isArray(headers)) {
    for (let i = 0; i < headers.length; i += 2) {
      if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
        ret.push(headers[i], headers[i + 1])
      }
    }
  } else if (headers && typeof headers === 'object') {
    const entries = util.hasSafeIterator(headers) ? headers : Object.entries(headers)

    for (const [key, value] of entries) {
      if (!shouldRemoveHeader(key, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
        ret.push(key, value)
      }
    }
  } else {
    assert(headers == null, 'headers must be an object or an array')
  }
  return ret
}

module.exports = RedirectHandler
