// https://github.com/Ethan-Arrowood/undici-fetch

'use strict';

const { kConstruct } = require('../../core/symbols');
const { kEnumerableProperty } = require('../../core/util');
const {
  iteratorMixin,
  isValidHeaderName,
  isValidHeaderValue,
} = require('./util');
const { webidl } = require('./webidl');
const assert = require('node:assert');
const util = require('node:util');

/**
 * @param {number} code
 * @returns {code is (0x0a | 0x0d | 0x09 | 0x20)}
 */
function isHTTPWhiteSpaceCharCode(code) {
  return code === 0x0a || code === 0x0d || code === 0x09 || code === 0x20;
}

/**
 * @see https://fetch.spec.whatwg.org/#concept-header-value-normalize
 * @param {string} potentialValue
 * @returns {string}
 */
function headerValueNormalize(potentialValue) {
  //  To normalize a byte sequence potentialValue, remove
  //  any leading and trailing HTTP whitespace bytes from
  //  potentialValue.
  let i = 0;
  let j = potentialValue.length;

  while (j > i && isHTTPWhiteSpaceCharCode(potentialValue.charCodeAt(j - 1)))
    --j;
  while (j > i && isHTTPWhiteSpaceCharCode(potentialValue.charCodeAt(i))) ++i;

  return i === 0 && j === potentialValue.length ?
      potentialValue
    : potentialValue.substring(i, j);
}

/**
 * @param {Headers} headers
 * @param {Array|Object} object
 */
function fill(headers, object) {
  // To fill a Headers object headers with a given object object, run these steps:

  // 1. If object is a sequence, then for each header in object:
  // Note: webidl conversion to array has already been done.
  if (Array.isArray(object)) {
    for (let i = 0; i < object.length; ++i) {
      const header = object[i];
      // 1. If header does not contain exactly two items, then throw a TypeError.
      if (header.length !== 2) {
        throw webidl.errors.exception({
          header: 'Headers constructor',
          message: `expected name/value pair to be length 2, found ${header.length}.`,
        });
      }

      // 2. Append (header’s first item, header’s second item) to headers.
      appendHeader(headers, header[0], header[1]);
    }
  } else if (typeof object === 'object' && object !== null) {
    // Note: null should throw

    // 2. Otherwise, object is a record, then for each key → value in object,
    //    append (key, value) to headers
    const keys = Object.keys(object);
    for (let i = 0; i < keys.length; ++i) {
      appendHeader(headers, keys[i], object[keys[i]]);
    }
  } else {
    throw webidl.errors.conversionFailed({
      prefix: 'Headers constructor',
      argument: 'Argument 1',
      types: [
        'sequence<sequence<ByteString>>',
        'record<ByteString, ByteString>',
      ],
    });
  }
}

/**
 * @see https://fetch.spec.whatwg.org/#concept-headers-append
 * @param {Headers} headers
 * @param {string} name
 * @param {string} value
 */
function appendHeader(headers, name, value) {
  // 1. Normalize value.
  value = headerValueNormalize(value);

  // 2. If name is not a header name or value is not a
  //    header value, then throw a TypeError.
  if (!isValidHeaderName(name)) {
    throw webidl.errors.invalidArgument({
      prefix: 'Headers.append',
      value: name,
      type: 'header name',
    });
  } else if (!isValidHeaderValue(value)) {
    throw webidl.errors.invalidArgument({
      prefix: 'Headers.append',
      value,
      type: 'header value',
    });
  }

  // 3. If headers’s guard is "immutable", then throw a TypeError.
  // 4. Otherwise, if headers’s guard is "request" and name is a
  //    forbidden header name, return.
  // 5. Otherwise, if headers’s guard is "request-no-cors":
  //    TODO
  // Note: undici does not implement forbidden header names
  if (getHeadersGuard(headers) === 'immutable') {
    throw new TypeError('immutable');
  }

  // 6. Otherwise, if headers’s guard is "response" and name is a
  //    forbidden response-header name, return.

  // 7. Append (name, value) to headers’s header list.
  return getHeadersList(headers).append(name, value, false);

  // 8. If headers’s guard is "request-no-cors", then remove
  //    privileged no-CORS request headers from headers
}

// https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine
/**
 * @param {Headers} target
 */
function headersListSortAndCombine(target) {
  const headersList = getHeadersList(target);

  if (!headersList) {
    return [];
  }

  if (headersList.sortedMap) {
    return headersList.sortedMap;
  }

  // 1. Let headers be an empty list of headers with the key being the name
  //    and value the value.
  const headers = [];

  // 2. Let names be the result of convert header names to a sorted-lowercase
  //    set with all the names of the headers in list.
  const names = headersList.toSortedArray();

  const cookies = headersList.cookies;

  // fast-path
  if (cookies === null || cookies.length === 1) {
    // Note: The non-null assertion of value has already been done by `HeadersList#toSortedArray`
    return (headersList.sortedMap = names);
  }

  // 3. For each name of names:
  for (let i = 0; i < names.length; ++i) {
    const { 0: name, 1: value } = names[i];
    // 1. If name is `set-cookie`, then:
    if (name === 'set-cookie') {
      // 1. Let values be a list of all values of headers in list whose name
      //    is a byte-case-insensitive match for name, in order.

      // 2. For each value of values:
      // 1. Append (name, value) to headers.
      for (let j = 0; j < cookies.length; ++j) {
        headers.push([name, cookies[j]]);
      }
    } else {
      // 2. Otherwise:

      // 1. Let value be the result of getting name from list.

      // 2. Assert: value is non-null.
      // Note: This operation was done by `HeadersList#toSortedArray`.

      // 3. Append (name, value) to headers.
      headers.push([name, value]);
    }
  }

  // 4. Return headers.
  return (headersList.sortedMap = headers);
}

function compareHeaderName(a, b) {
  return a[0] < b[0] ? -1 : 1;
}

class HeadersList {
  /** @type {[string, string][]|null} */
  cookies = null;

  sortedMap;
  headersMap;

  constructor(init) {
    if (init instanceof HeadersList) {
      this.headersMap = new Map(init.headersMap);
      this.sortedMap = init.sortedMap;
      this.cookies = init.cookies === null ? null : [...init.cookies];
    } else {
      this.headersMap = new Map(init);
      this.sortedMap = null;
    }
  }

  /**
   * @see https://fetch.spec.whatwg.org/#header-list-contains
   * @param {string} name
   * @param {boolean} isLowerCase
   */
  contains(name, isLowerCase) {
    // A header list list contains a header name name if list
    // contains a header whose name is a byte-case-insensitive
    // match for name.

    return this.headersMap.has(isLowerCase ? name : name.toLowerCase());
  }

  clear() {
    this.headersMap.clear();
    this.sortedMap = null;
    this.cookies = null;
  }

  /**
   * @see https://fetch.spec.whatwg.org/#concept-header-list-append
   * @param {string} name
   * @param {string} value
   * @param {boolean} isLowerCase
   */
  append(name, value, isLowerCase) {
    this.sortedMap = null;

    // 1. If list contains name, then set name to the first such
    //    header’s name.
    const lowercaseName = isLowerCase ? name : name.toLowerCase();
    const exists = this.headersMap.get(lowercaseName);

    // 2. Append (name, value) to list.
    if (exists) {
      const delimiter = lowercaseName === 'cookie' ? '; ' : ', ';
      this.headersMap.set(lowercaseName, {
        name: exists.name,
        value: `${exists.value}${delimiter}${value}`,
      });
    } else {
      this.headersMap.set(lowercaseName, { name, value });
    }

    if (lowercaseName === 'set-cookie') {
      (this.cookies ??= []).push(value);
    }
  }

  /**
   * @see https://fetch.spec.whatwg.org/#concept-header-list-set
   * @param {string} name
   * @param {string} value
   * @param {boolean} isLowerCase
   */
  set(name, value, isLowerCase) {
    this.sortedMap = null;
    const lowercaseName = isLowerCase ? name : name.toLowerCase();

    if (lowercaseName === 'set-cookie') {
      this.cookies = [value];
    }

    // 1. If list contains name, then set the value of
    //    the first such header to value and remove the
    //    others.
    // 2. Otherwise, append header (name, value) to list.
    this.headersMap.set(lowercaseName, { name, value });
  }

  /**
   * @see https://fetch.spec.whatwg.org/#concept-header-list-delete
   * @param {string} name
   * @param {boolean} isLowerCase
   */
  delete(name, isLowerCase) {
    this.sortedMap = null;
    if (!isLowerCase) name = name.toLowerCase();

    if (name === 'set-cookie') {
      this.cookies = null;
    }

    this.headersMap.delete(name);
  }

  /**
   * @see https://fetch.spec.whatwg.org/#concept-header-list-get
   * @param {string} name
   * @param {boolean} isLowerCase
   * @returns {string | null}
   */
  get(name, isLowerCase) {
    // 1. If list does not contain name, then return null.
    // 2. Return the values of all headers in list whose name
    //    is a byte-case-insensitive match for name,
    //    separated from each other by 0x2C 0x20, in order.
    return (
      this.headersMap.get(isLowerCase ? name : name.toLowerCase())?.value ??
      null
    );
  }

  *[Symbol.iterator]() {
    // use the lowercased name
    for (const {
      0: name,
      1: { value },
    } of this.headersMap) {
      yield [name, value];
    }
  }

  get entries() {
    const headers = {};

    if (this.headersMap.size !== 0) {
      for (const { name, value } of this.headersMap.values()) {
        headers[name] = value;
      }
    }

    return headers;
  }

  rawValues() {
    return this.headersMap.values();
  }

  get entriesList() {
    const headers = [];

    if (this.headersMap.size !== 0) {
      for (const {
        0: lowerName,
        1: { name, value },
      } of this.headersMap) {
        if (lowerName === 'set-cookie') {
          for (const cookie of this.cookies) {
            headers.push([name, cookie]);
          }
        } else {
          headers.push([name, value]);
        }
      }
    }

    return headers;
  }

  // https://fetch.spec.whatwg.org/#convert-header-names-to-a-sorted-lowercase-set
  toSortedArray() {
    const size = this.headersMap.size;
    const array = new Array(size);
    // In most cases, you will use the fast-path.
    // fast-path: Use binary insertion sort for small arrays.
    if (size <= 32) {
      if (size === 0) {
        // If empty, it is an empty array. To avoid the first index assignment.
        return array;
      }
      // Improve performance by unrolling loop and avoiding double-loop.
      // Double-loop-less version of the binary insertion sort.
      const iterator = this.headersMap[Symbol.iterator]();
      const firstValue = iterator.next().value;
      // set [name, value] to first index.
      array[0] = [firstValue[0], firstValue[1].value];
      // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine
      // 3.2.2. Assert: value is non-null.
      assert(firstValue[1].value !== null);
      for (
        let i = 1, j = 0, right = 0, left = 0, pivot = 0, x, value;
        i < size;
        ++i
      ) {
        // get next value
        value = iterator.next().value;
        // set [name, value] to current index.
        x = array[i] = [value[0], value[1].value];
        // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine
        // 3.2.2. Assert: value is non-null.
        assert(x[1] !== null);
        left = 0;
        right = i;
        // binary search
        while (left < right) {
          // middle index
          pivot = left + ((right - left) >> 1);
          // compare header name
          if (array[pivot][0] <= x[0]) {
            left = pivot + 1;
          } else {
            right = pivot;
          }
        }
        if (i !== pivot) {
          j = i;
          while (j > left) {
            array[j] = array[--j];
          }
          array[left] = x;
        }
      }
      /* c8 ignore next 4 */
      if (!iterator.next().done) {
        // This is for debugging and will never be called.
        throw new TypeError('Unreachable');
      }
      return array;
    } else {
      // This case would be a rare occurrence.
      // slow-path: fallback
      let i = 0;
      for (const {
        0: name,
        1: { value },
      } of this.headersMap) {
        array[i++] = [name, value];
        // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine
        // 3.2.2. Assert: value is non-null.
        assert(value !== null);
      }
      return array.sort(compareHeaderName);
    }
  }
}

// https://fetch.spec.whatwg.org/#headers-class
class Headers {
  #guard;
  /**
   * @type {HeadersList}
   */
  #headersList;

  /**
   * @param {HeadersInit|Symbol} [init]
   * @returns
   */
  constructor(init = undefined) {
    webidl.util.markAsUncloneable(this);

    if (init === kConstruct) {
      return;
    }

    this.#headersList = new HeadersList();

    // The new Headers(init) constructor steps are:

    // 1. Set this’s guard to "none".
    this.#guard = 'none';

    // 2. If init is given, then fill this with init.
    if (init !== undefined) {
      init = webidl.converters.HeadersInit(init, 'Headers constructor', 'init');
      fill(this, init);
    }
  }

  // https://fetch.spec.whatwg.org/#dom-headers-append
  append(name, value) {
    webidl.brandCheck(this, Headers);

    webidl.argumentLengthCheck(arguments, 2, 'Headers.append');

    const prefix = 'Headers.append';
    name = webidl.converters.ByteString(name, prefix, 'name');
    value = webidl.converters.ByteString(value, prefix, 'value');

    return appendHeader(this, name, value);
  }

  // https://fetch.spec.whatwg.org/#dom-headers-delete
  delete(name) {
    webidl.brandCheck(this, Headers);

    webidl.argumentLengthCheck(arguments, 1, 'Headers.delete');

    const prefix = 'Headers.delete';
    name = webidl.converters.ByteString(name, prefix, 'name');

    // 1. If name is not a header name, then throw a TypeError.
    if (!isValidHeaderName(name)) {
      throw webidl.errors.invalidArgument({
        prefix: 'Headers.delete',
        value: name,
        type: 'header name',
      });
    }

    // 2. If this’s guard is "immutable", then throw a TypeError.
    // 3. Otherwise, if this’s guard is "request" and name is a
    //    forbidden header name, return.
    // 4. Otherwise, if this’s guard is "request-no-cors", name
    //    is not a no-CORS-safelisted request-header name, and
    //    name is not a privileged no-CORS request-header name,
    //    return.
    // 5. Otherwise, if this’s guard is "response" and name is
    //    a forbidden response-header name, return.
    // Note: undici does not implement forbidden header names
    if (this.#guard === 'immutable') {
      throw new TypeError('immutable');
    }

    // 6. If this’s header list does not contain name, then
    //    return.
    if (!this.#headersList.contains(name, false)) {
      return;
    }

    // 7. Delete name from this’s header list.
    // 8. If this’s guard is "request-no-cors", then remove
    //    privileged no-CORS request headers from this.
    this.#headersList.delete(name, false);
  }

  // https://fetch.spec.whatwg.org/#dom-headers-get
  get(name) {
    webidl.brandCheck(this, Headers);

    webidl.argumentLengthCheck(arguments, 1, 'Headers.get');

    const prefix = 'Headers.get';
    name = webidl.converters.ByteString(name, prefix, 'name');

    // 1. If name is not a header name, then throw a TypeError.
    if (!isValidHeaderName(name)) {
      throw webidl.errors.invalidArgument({
        prefix,
        value: name,
        type: 'header name',
      });
    }

    // 2. Return the result of getting name from this’s header
    //    list.
    return this.#headersList.get(name, false);
  }

  // https://fetch.spec.whatwg.org/#dom-headers-has
  has(name) {
    webidl.brandCheck(this, Headers);

    webidl.argumentLengthCheck(arguments, 1, 'Headers.has');

    const prefix = 'Headers.has';
    name = webidl.converters.ByteString(name, prefix, 'name');

    // 1. If name is not a header name, then throw a TypeError.
    if (!isValidHeaderName(name)) {
      throw webidl.errors.invalidArgument({
        prefix,
        value: name,
        type: 'header name',
      });
    }

    // 2. Return true if this’s header list contains name;
    //    otherwise false.
    return this.#headersList.contains(name, false);
  }

  // https://fetch.spec.whatwg.org/#dom-headers-set
  set(name, value) {
    webidl.brandCheck(this, Headers);

    webidl.argumentLengthCheck(arguments, 2, 'Headers.set');

    const prefix = 'Headers.set';
    name = webidl.converters.ByteString(name, prefix, 'name');
    value = webidl.converters.ByteString(value, prefix, 'value');

    // 1. Normalize value.
    value = headerValueNormalize(value);

    // 2. If name is not a header name or value is not a
    //    header value, then throw a TypeError.
    if (!isValidHeaderName(name)) {
      throw webidl.errors.invalidArgument({
        prefix,
        value: name,
        type: 'header name',
      });
    } else if (!isValidHeaderValue(value)) {
      throw webidl.errors.invalidArgument({
        prefix,
        value,
        type: 'header value',
      });
    }

    // 3. If this’s guard is "immutable", then throw a TypeError.
    // 4. Otherwise, if this’s guard is "request" and name is a
    //    forbidden header name, return.
    // 5. Otherwise, if this’s guard is "request-no-cors" and
    //    name/value is not a no-CORS-safelisted request-header,
    //    return.
    // 6. Otherwise, if this’s guard is "response" and name is a
    //    forbidden response-header name, return.
    // Note: undici does not implement forbidden header names
    if (this.#guard === 'immutable') {
      throw new TypeError('immutable');
    }

    // 7. Set (name, value) in this’s header list.
    // 8. If this’s guard is "request-no-cors", then remove
    //    privileged no-CORS request headers from this
    this.#headersList.set(name, value, false);
  }

  // https://fetch.spec.whatwg.org/#dom-headers-getsetcookie
  getSetCookie() {
    webidl.brandCheck(this, Headers);

    // 1. If this’s header list does not contain `Set-Cookie`, then return « ».
    // 2. Return the values of all headers in this’s header list whose name is
    //    a byte-case-insensitive match for `Set-Cookie`, in order.

    const list = this.#headersList.cookies;

    if (list) {
      return [...list];
    }

    return [];
  }

  [util.inspect.custom](depth, options) {
    options.depth ??= depth;

    return `Headers ${util.formatWithOptions(options, this.#headersList.entries)}`;
  }

  static getHeadersGuard(o) {
    return o.#guard;
  }

  static setHeadersGuard(o, guard) {
    o.#guard = guard;
  }

  /**
   * @param {Headers} o
   */
  static getHeadersList(o) {
    return o.#headersList;
  }

  /**
   * @param {Headers} target
   * @param {HeadersList} list
   */
  static setHeadersList(target, list) {
    target.#headersList = list;
  }
}

const { getHeadersGuard, setHeadersGuard, getHeadersList, setHeadersList } =
  Headers;
Reflect.deleteProperty(Headers, 'getHeadersGuard');
Reflect.deleteProperty(Headers, 'setHeadersGuard');
Reflect.deleteProperty(Headers, 'getHeadersList');
Reflect.deleteProperty(Headers, 'setHeadersList');

iteratorMixin('Headers', Headers, headersListSortAndCombine, 0, 1);

Object.defineProperties(Headers.prototype, {
  append: kEnumerableProperty,
  delete: kEnumerableProperty,
  get: kEnumerableProperty,
  has: kEnumerableProperty,
  set: kEnumerableProperty,
  getSetCookie: kEnumerableProperty,
  [Symbol.toStringTag]: {
    value: 'Headers',
    configurable: true,
  },
  [util.inspect.custom]: {
    enumerable: false,
  },
});

webidl.converters.HeadersInit = function (V, prefix, argument) {
  if (webidl.util.Type(V) === webidl.util.Types.OBJECT) {
    const iterator = Reflect.get(V, Symbol.iterator);

    // A work-around to ensure we send the properly-cased Headers when V is a Headers object.
    // Read https://github.com/nodejs/undici/pull/3159#issuecomment-2075537226 before touching, please.
    if (!util.types.isProxy(V) && iterator === Headers.prototype.entries) {
      // Headers object
      try {
        return getHeadersList(V).entriesList;
      } catch {
        // fall-through
      }
    }

    if (typeof iterator === 'function') {
      return webidl.converters['sequence<sequence<ByteString>>'](
        V,
        prefix,
        argument,
        iterator.bind(V)
      );
    }

    return webidl.converters['record<ByteString, ByteString>'](
      V,
      prefix,
      argument
    );
  }

  throw webidl.errors.conversionFailed({
    prefix: 'Headers constructor',
    argument: 'Argument 1',
    types: ['sequence<sequence<ByteString>>', 'record<ByteString, ByteString>'],
  });
};

module.exports = {
  fill,
  // for test.
  compareHeaderName,
  Headers,
  HeadersList,
  getHeadersGuard,
  setHeadersGuard,
  setHeadersList,
  getHeadersList,
};