/*
 This file is part of GNU Taler
 (C) 2022 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

import { Logger } from "@gnu-taler/taler-util";

type HttpMethod =
  | "get"
  | "GET"
  | "delete"
  | "DELETE"
  | "head"
  | "HEAD"
  | "options"
  | "OPTIONS"
  | "post"
  | "POST"
  | "put"
  | "PUT"
  | "patch"
  | "PATCH"
  | "purge"
  | "PURGE"
  | "link"
  | "LINK"
  | "unlink"
  | "UNLINK";

/**
 * @deprecated do not use it, it will be removed
 */
export type Query<Req, Res> = {
  method: HttpMethod;
  url: string;
  code?: number;
};

type ExpectationValues = {
  query: Query<any, any>;
  auth?: string;
  params?: {
    // eslint-disable-next-line @typescript-eslint/ban-types
    request?: object;
    qparam?: Record<string, string>;
    // eslint-disable-next-line @typescript-eslint/ban-types
    response?: object;
  };
};

type TestValues = {
  currentExpectedQuery: ExpectationValues | undefined;
  lastQuery: ExpectationValues | undefined;
};

const logger = new Logger("testing/mock.ts");

type MockedResponse = {
  queryMade: ExpectationValues;
  expectedQuery?: ExpectationValues;
};

/**
 * @deprecated do not use it, it will be removed
 */
export abstract class MockEnvironment {
  expectations: Array<ExpectationValues> = [];
  queriesMade: Array<ExpectationValues> = [];
  index = 0;

  debug: boolean;
  constructor(debug: boolean) {
    this.debug = debug;
    this.saveRequestAndGetMockedResponse.bind(this);
  }

  public addRequestExpectation<
    // eslint-disable-next-line @typescript-eslint/ban-types
    RequestType extends object,
    // eslint-disable-next-line @typescript-eslint/ban-types
    ResponseType extends object,
  >(
    query: Query<RequestType, ResponseType>,
    params: {
      auth?: string;
      request?: RequestType;
      qparam?: any;
      response?: ResponseType;
    },
  ): void {
    const expected = { query, params, auth: params.auth };
    this.expectations.push(expected);
    if (this.debug) {
      logger.info("saving query as expected", expected);
    }
  }

  public saveRequestAndGetMockedResponse<
    // eslint-disable-next-line @typescript-eslint/ban-types
    RequestType extends object,
    // eslint-disable-next-line @typescript-eslint/ban-types
    ResponseType extends object,
  >(
    query: Query<RequestType, ResponseType>,
    params: {
      auth?: string;
      request?: RequestType;
      qparam?: any;
      response?: ResponseType;
    },
  ): MockedResponse {
    const queryMade = { query, params, auth: params.auth };
    this.queriesMade.push(queryMade);
    const expectedQuery = this.expectations[this.index];
    if (!expectedQuery) {
      if (this.debug) {
        logger.info("unexpected query made", queryMade);
      }
      return { queryMade };
    }

    if (this.debug) {
      logger.info("tracking query made", {
        queryMade,
        expectedQuery,
      });
    }
    this.index++;
    return { queryMade, expectedQuery };
  }

  public assertJustExpectedRequestWereMade(): AssertStatus {
    let queryNumber = 0;

    while (queryNumber < this.expectations.length) {
      const r = this.assertNextRequest(queryNumber);
      if (r.result !== "ok") return r;
      queryNumber++;
    }
    return this.assertNoMoreRequestWereMade(queryNumber);
  }

  private getLastTestValues(idx: number): TestValues {
    const currentExpectedQuery = this.expectations[idx];
    const lastQuery = this.queriesMade[idx];

    return { currentExpectedQuery, lastQuery };
  }

  private assertNoMoreRequestWereMade(idx: number): AssertStatus {
    const { currentExpectedQuery, lastQuery } = this.getLastTestValues(idx);

    if (lastQuery !== undefined) {
      return {
        result: "error-did-one-more",
        made: lastQuery,
      };
    }
    if (currentExpectedQuery !== undefined) {
      return {
        result: "error-did-one-less",
        expected: currentExpectedQuery,
      };
    }

    return {
      result: "ok",
    };
  }

  private assertNextRequest(index: number): AssertStatus {
    const { currentExpectedQuery, lastQuery } = this.getLastTestValues(index);

    if (!currentExpectedQuery) {
      return {
        result: "error-query-missing",
      };
    }

    if (!lastQuery) {
      return {
        result: "error-did-one-less",
        expected: currentExpectedQuery,
      };
    }

    if (lastQuery.query.method) {
      if (currentExpectedQuery.query.method !== lastQuery.query.method) {
        return {
          result: "error-difference",
          diff: "method",
          last: lastQuery.query.method,
          expected: currentExpectedQuery.query.method,
          index,
        };
      }
      if (currentExpectedQuery.query.url !== lastQuery.query.url) {
        return {
          result: "error-difference",
          diff: "url",
          last: lastQuery.query.url,
          expected: currentExpectedQuery.query.url,
          index,
        };
      }
    }
    if (
      !deepEquals(
        currentExpectedQuery.params?.request,
        lastQuery.params?.request,
      )
    ) {
      return {
        result: "error-difference",
        diff: "query-body",
        expected: currentExpectedQuery.params?.request,
        last: lastQuery.params?.request,
        index,
      };
    }
    if (
      !deepEquals(currentExpectedQuery.params?.qparam, lastQuery.params?.qparam)
    ) {
      return {
        result: "error-difference",
        diff: "query-params",
        expected: currentExpectedQuery.params?.qparam,
        last: lastQuery.params?.qparam,
        index,
      };
    }
    if (!deepEquals(currentExpectedQuery.auth, lastQuery.auth)) {
      return {
        result: "error-difference",
        diff: "query-auth",
        expected: currentExpectedQuery.auth,
        last: lastQuery.auth,
        index,
      };
    }

    return {
      result: "ok",
    };
  }
}

type AssertStatus =
  | AssertOk
  | AssertQueryNotMadeButExpected
  | AssertQueryMadeButNotExpected
  | AssertQueryMissing
  | AssertExpectedQueryMethodMismatch
  | AssertExpectedQueryUrlMismatch
  | AssertExpectedQueryAuthMismatch
  | AssertExpectedQueryBodyMismatch
  | AssertExpectedQueryParamsMismatch;

interface AssertOk {
  result: "ok";
}

//trying to assert for a expected query but there is
//no expected query in the queue
interface AssertQueryMissing {
  result: "error-query-missing";
}

//tested component did one more query that expected
interface AssertQueryNotMadeButExpected {
  result: "error-did-one-more";
  made: ExpectationValues;
}

//tested component didn't make an expected query
interface AssertQueryMadeButNotExpected {
  result: "error-did-one-less";
  expected: ExpectationValues;
}

interface AssertExpectedQueryMethodMismatch {
  result: "error-difference";
  diff: "method";
  last: string;
  expected: string;
  index: number;
}
interface AssertExpectedQueryUrlMismatch {
  result: "error-difference";
  diff: "url";
  last: string;
  expected: string;
  index: number;
}
interface AssertExpectedQueryAuthMismatch {
  result: "error-difference";
  diff: "query-auth";
  last: string | undefined;
  expected: string | undefined;
  index: number;
}
interface AssertExpectedQueryBodyMismatch {
  result: "error-difference";
  diff: "query-body";
  last: any;
  expected: any;
  index: number;
}
interface AssertExpectedQueryParamsMismatch {
  result: "error-difference";
  diff: "query-params";
  last: any;
  expected: any;
  index: number;
}

/**
 * helpers
 *
 */
export type Tester = (a: any, b: any) => boolean | undefined;

function deepEquals(
  a: unknown,
  b: unknown,
  aStack: Array<unknown> = [],
  bStack: Array<unknown> = [],
): boolean {
  //one if the element is null or undefined
  if (a === null || b === null || b === undefined || a === undefined) {
    return a === b;
  }
  //both are errors
  if (a instanceof Error && b instanceof Error) {
    return a.message == b.message;
  }
  //is the same object
  if (Object.is(a, b)) {
    return true;
  }
  //both the same class
  const name = Object.prototype.toString.call(a);
  if (name != Object.prototype.toString.call(b)) {
    return false;
  }
  //
  switch (name) {
    case "[object Boolean]":
    case "[object String]":
    case "[object Number]":
      if (typeof a !== typeof b) {
        // One is a primitive, one a `new Primitive()`
        return false;
      } else if (typeof a !== "object" && typeof b !== "object") {
        // both are proper primitives
        return Object.is(a, b);
      } else {
        // both are `new Primitive()`s
        return Object.is(a.valueOf(), b.valueOf());
      }
    case "[object Date]": {
      const _a = a as Date;
      const _b = b as Date;
      return _a == _b;
    }
    case "[object RegExp]": {
      const _a = a as RegExp;
      const _b = b as RegExp;
      return _a.source === _b.source && _a.flags === _b.flags;
    }
    case "[object Array]": {
      const _a = a as Array<any>;
      const _b = b as Array<any>;
      if (_a.length !== _b.length) {
        return false;
      }
    }
  }
  if (typeof a !== "object" || typeof b !== "object") {
    return false;
  }

  if (
    typeof a === "object" &&
    typeof b === "object" &&
    !Array.isArray(a) &&
    !Array.isArray(b) &&
    hasIterator(a) &&
    hasIterator(b)
  ) {
    return iterable(a, b);
  }

  // Used to detect circular references.
  let length = aStack.length;
  while (length--) {
    if (aStack[length] === a) {
      return bStack[length] === b;
    } else if (bStack[length] === b) {
      return false;
    }
  }
  aStack.push(a);
  bStack.push(b);

  const aKeys = allKeysFromObject(a);
  const bKeys = allKeysFromObject(b);
  let keySize = aKeys.length;

  //same number of keys
  if (bKeys.length !== keySize) {
    return false;
  }

  let keyIterator: string;
  while (keySize--) {
    // eslint-disable-next-line @typescript-eslint/ban-types
    const _a = a as Record<string, object>;
    // eslint-disable-next-line @typescript-eslint/ban-types
    const _b = b as Record<string, object>;

    keyIterator = aKeys[keySize];

    const de = deepEquals(_a[keyIterator], _b[keyIterator], aStack, bStack);
    if (!de) {
      return false;
    }
  }

  aStack.pop();
  bStack.pop();

  return true;
}

// eslint-disable-next-line @typescript-eslint/ban-types
function allKeysFromObject(obj: object): Array<string> {
  const keys = [];
  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      keys.push(key);
    }
  }
  return keys;
}

const IteratorSymbol = Symbol.iterator;

function hasIterator(object: any): boolean {
  return !!(object != null && object[IteratorSymbol]);
}

function iterable(
  a: unknown,
  b: unknown,
  aStack: Array<unknown> = [],
  bStack: Array<unknown> = [],
): boolean {
  if (a === null || b === null || b === undefined || a === undefined) {
    return a === b;
  }
  if (a.constructor !== b.constructor) {
    return false;
  }
  let length = aStack.length;
  while (length--) {
    if (aStack[length] === a) {
      return bStack[length] === b;
    }
  }
  aStack.push(a);
  bStack.push(b);

  const aIterator = (a as any)[IteratorSymbol]();
  const bIterator = (b as any)[IteratorSymbol]();

  const nextA = aIterator.next();
  while (nextA.done) {
    const nextB = bIterator.next();
    if (nextB.done || !deepEquals(nextA.value, nextB.value)) {
      return false;
    }
  }
  if (!bIterator.next().done) {
    return false;
  }

  // Remove the first value from the stack of traversed values.
  aStack.pop();
  bStack.pop();
  return true;
}
