'use strict';

const { kMockCallHistoryAddLog } = require('./mock-symbols');
const { InvalidArgumentError } = require('../core/errors');

function handleFilterCallsWithOptions(criteria, options, handler, store) {
  switch (options.operator) {
    case 'OR':
      store.push(...handler(criteria));

      return store;
    case 'AND':
      return handler.call({ logs: store }, criteria);
    default:
      // guard -- should never happens because buildAndValidateFilterCallsOptions is called before
      throw new InvalidArgumentError(
        "options.operator must to be a case insensitive string equal to 'OR' or 'AND'"
      );
  }
}

function buildAndValidateFilterCallsOptions(options = {}) {
  const finalOptions = {};

  if ('operator' in options) {
    if (
      typeof options.operator !== 'string' ||
      (options.operator.toUpperCase() !== 'OR' &&
        options.operator.toUpperCase() !== 'AND')
    ) {
      throw new InvalidArgumentError(
        "options.operator must to be a case insensitive string equal to 'OR' or 'AND'"
      );
    }

    return {
      ...finalOptions,
      operator: options.operator.toUpperCase(),
    };
  }

  return finalOptions;
}

function makeFilterCalls(parameterName) {
  return (parameterValue) => {
    if (typeof parameterValue === 'string' || parameterValue == null) {
      return this.logs.filter((log) => {
        return log[parameterName] === parameterValue;
      });
    }
    if (parameterValue instanceof RegExp) {
      return this.logs.filter((log) => {
        return parameterValue.test(log[parameterName]);
      });
    }

    throw new InvalidArgumentError(
      `${parameterName} parameter should be one of string, regexp, undefined or null`
    );
  };
}
function computeUrlWithMaybeSearchParameters(requestInit) {
  // path can contains query url parameters
  // or query can contains query url parameters
  try {
    const url = new URL(requestInit.path, requestInit.origin);

    // requestInit.path contains query url parameters
    // requestInit.query is then undefined
    if (url.search.length !== 0) {
      return url;
    }

    // requestInit.query can be populated here
    url.search = new URLSearchParams(requestInit.query).toString();

    return url;
  } catch (error) {
    throw new InvalidArgumentError(
      'An error occurred when computing MockCallHistoryLog.url',
      { cause: error }
    );
  }
}

class MockCallHistoryLog {
  constructor(requestInit = {}) {
    this.body = requestInit.body;
    this.headers = requestInit.headers;
    this.method = requestInit.method;

    const url = computeUrlWithMaybeSearchParameters(requestInit);

    this.fullUrl = url.toString();
    this.origin = url.origin;
    this.path = url.pathname;
    this.searchParams = Object.fromEntries(url.searchParams);
    this.protocol = url.protocol;
    this.host = url.host;
    this.port = url.port;
    this.hash = url.hash;
  }

  toMap() {
    return new Map([
      ['protocol', this.protocol],
      ['host', this.host],
      ['port', this.port],
      ['origin', this.origin],
      ['path', this.path],
      ['hash', this.hash],
      ['searchParams', this.searchParams],
      ['fullUrl', this.fullUrl],
      ['method', this.method],
      ['body', this.body],
      ['headers', this.headers],
    ]);
  }

  toString() {
    const options = {
      betweenKeyValueSeparator: '->',
      betweenPairSeparator: '|',
    };
    let result = '';

    this.toMap().forEach((value, key) => {
      if (typeof value === 'string' || value === undefined || value === null) {
        result = `${result}${key}${options.betweenKeyValueSeparator}${value}${options.betweenPairSeparator}`;
      }
      if (
        (typeof value === 'object' && value !== null) ||
        Array.isArray(value)
      ) {
        result = `${result}${key}${options.betweenKeyValueSeparator}${JSON.stringify(value)}${options.betweenPairSeparator}`;
      }
      // maybe miss something for non Record / Array headers and searchParams here
    });

    // delete last betweenPairSeparator
    return result.slice(0, -1);
  }
}

class MockCallHistory {
  logs = [];

  calls() {
    return this.logs;
  }

  firstCall() {
    return this.logs.at(0);
  }

  lastCall() {
    return this.logs.at(-1);
  }

  nthCall(number) {
    if (typeof number !== 'number') {
      throw new InvalidArgumentError('nthCall must be called with a number');
    }
    if (!Number.isInteger(number)) {
      throw new InvalidArgumentError('nthCall must be called with an integer');
    }
    if (Math.sign(number) !== 1) {
      throw new InvalidArgumentError(
        'nthCall must be called with a positive value. use firstCall or lastCall instead'
      );
    }

    // non zero based index. this is more human readable
    return this.logs.at(number - 1);
  }

  filterCalls(criteria, options) {
    // perf
    if (this.logs.length === 0) {
      return this.logs;
    }
    if (typeof criteria === 'function') {
      return this.logs.filter(criteria);
    }
    if (criteria instanceof RegExp) {
      return this.logs.filter((log) => {
        return criteria.test(log.toString());
      });
    }
    if (typeof criteria === 'object' && criteria !== null) {
      // no criteria - returning all logs
      if (Object.keys(criteria).length === 0) {
        return this.logs;
      }

      const finalOptions = {
        operator: 'OR',
        ...buildAndValidateFilterCallsOptions(options),
      };

      let maybeDuplicatedLogsFiltered = [];
      if ('protocol' in criteria) {
        maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(
          criteria.protocol,
          finalOptions,
          this.filterCallsByProtocol,
          maybeDuplicatedLogsFiltered
        );
      }
      if ('host' in criteria) {
        maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(
          criteria.host,
          finalOptions,
          this.filterCallsByHost,
          maybeDuplicatedLogsFiltered
        );
      }
      if ('port' in criteria) {
        maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(
          criteria.port,
          finalOptions,
          this.filterCallsByPort,
          maybeDuplicatedLogsFiltered
        );
      }
      if ('origin' in criteria) {
        maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(
          criteria.origin,
          finalOptions,
          this.filterCallsByOrigin,
          maybeDuplicatedLogsFiltered
        );
      }
      if ('path' in criteria) {
        maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(
          criteria.path,
          finalOptions,
          this.filterCallsByPath,
          maybeDuplicatedLogsFiltered
        );
      }
      if ('hash' in criteria) {
        maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(
          criteria.hash,
          finalOptions,
          this.filterCallsByHash,
          maybeDuplicatedLogsFiltered
        );
      }
      if ('fullUrl' in criteria) {
        maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(
          criteria.fullUrl,
          finalOptions,
          this.filterCallsByFullUrl,
          maybeDuplicatedLogsFiltered
        );
      }
      if ('method' in criteria) {
        maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(
          criteria.method,
          finalOptions,
          this.filterCallsByMethod,
          maybeDuplicatedLogsFiltered
        );
      }

      const uniqLogsFiltered = [...new Set(maybeDuplicatedLogsFiltered)];

      return uniqLogsFiltered;
    }

    throw new InvalidArgumentError(
      'criteria parameter should be one of function, regexp, or object'
    );
  }

  filterCallsByProtocol = makeFilterCalls.call(this, 'protocol');

  filterCallsByHost = makeFilterCalls.call(this, 'host');

  filterCallsByPort = makeFilterCalls.call(this, 'port');

  filterCallsByOrigin = makeFilterCalls.call(this, 'origin');

  filterCallsByPath = makeFilterCalls.call(this, 'path');

  filterCallsByHash = makeFilterCalls.call(this, 'hash');

  filterCallsByFullUrl = makeFilterCalls.call(this, 'fullUrl');

  filterCallsByMethod = makeFilterCalls.call(this, 'method');

  clear() {
    this.logs = [];
  }

  [kMockCallHistoryAddLog](requestInit) {
    const log = new MockCallHistoryLog(requestInit);

    this.logs.push(log);

    return log;
  }

  *[Symbol.iterator]() {
    for (const log of this.calls()) {
      yield log;
    }
  }
}

module.exports.MockCallHistory = MockCallHistory;
module.exports.MockCallHistoryLog = MockCallHistoryLog;