/*
MIT License

Copyright (c) 2021-Present Anthony Fu <https://github.com/antfu>
Copyright (c) 2021-Present Matias Capeletto <https://github.com/patak-dev>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
 */

// Extracted and modified from Vitest:  https://github.com/vitest-dev/vitest/blob/7a31a1ae4223aed3adf260e63ac3b3f7fab3c9d7/packages/expect/src/jest-asymmetric-matchers.ts

import ChaiPlugin = Chai.ChaiPlugin;

import { equals, isA, pluralize } from "./jest-utils";
import { stringify } from "./stringify";

export interface AsymmetricMatcherInterface {
  asymmetricMatch: (other: unknown) => boolean;
  toString: () => string;
  getExpectedType?: () => string;
  toAsymmetricMatcher?: () => string;
}

export abstract class AsymmetricMatcher<T>
  implements AsymmetricMatcherInterface
{
  // should have "jest" to be compatible with its ecosystem
  $$typeof = Symbol.for("jest.asymmetricMatcher");

  constructor(
    protected sample: T,
    protected inverse = false
  ) {}

  // protected getMatcherContext(expect?: Chai.ExpectStatic): State {
  //   return {
  //     equals,
  //     isNot: this.inverse,
  //     customTesters: getCustomEqualityTesters(),
  //     utils: {
  //       ...getMatcherUtils(),
  //       diff,
  //       stringify,
  //       iterableEquality,
  //       subsetEquality,
  //     },
  //   }
  // }

  abstract asymmetricMatch(other: unknown): boolean;
  abstract toString(): string;
  getExpectedType?(): string;
  toAsymmetricMatcher?(): string;

  // implement custom chai/loupe inspect for better AssertionError.message formatting
  // https://github.com/chaijs/loupe/blob/9b8a6deabcd50adc056a64fb705896194710c5c6/src/index.ts#L29
  [Symbol.for("chai/inspect")](options: { depth: number; truncate: number }) {
    // minimal pretty-format with simple manual truncation
    const result = stringify(this, options.depth, { min: true });
    if (result.length <= options.truncate) return result;
    return `${this.toString()}{…}`;
  }
}

export class StringContaining extends AsymmetricMatcher<string> {
  constructor(sample: string, inverse = false) {
    if (!isA("String", sample)) throw new Error("Expected is not a string");

    super(sample, inverse);
  }

  asymmetricMatch(other: string) {
    const result = isA("String", other) && other.includes(this.sample);

    return this.inverse ? !result : result;
  }

  toString() {
    return `String${this.inverse ? "Not" : ""}Containing`;
  }

  getExpectedType() {
    return "string";
  }
}

export class Anything extends AsymmetricMatcher<void> {
  asymmetricMatch(other: unknown) {
    return other != null;
  }

  toString() {
    return "Anything";
  }

  toAsymmetricMatcher() {
    return "Anything";
  }
}

export class ObjectContaining extends AsymmetricMatcher<
  Record<string, unknown>
> {
  constructor(sample: Record<string, unknown>, inverse = false) {
    super(sample, inverse);
  }

  getPrototype(obj: object) {
    if (Object.getPrototypeOf) return Object.getPrototypeOf(obj);

    if (obj.constructor.prototype === obj) return null;

    return obj.constructor.prototype;
  }

  hasProperty(obj: object | null, property: string): boolean {
    if (!obj) return false;

    if (Object.prototype.hasOwnProperty.call(obj, property)) return true;

    return this.hasProperty(this.getPrototype(obj), property);
  }

  asymmetricMatch(other: any) {
    if (typeof this.sample !== "object") {
      throw new TypeError(
        `You must provide an object to ${this.toString()}, not '${typeof this
          .sample}'.`
      );
    }

    let result = true;

    // const matcherContext = this.getMatcherContext()
    for (const property in this.sample) {
      if (
        !this.hasProperty(other, property) ||
        !equals(this.sample[property], other[property], [])
      ) {
        result = false;
        break;
      }
    }

    return this.inverse ? !result : result;
  }

  toString() {
    return `Object${this.inverse ? "Not" : ""}Containing`;
  }

  getExpectedType() {
    return "object";
  }
}

export class ArrayContaining<T = unknown> extends AsymmetricMatcher<Array<T>> {
  constructor(sample: Array<T>, inverse = false) {
    super(sample, inverse);
  }

  asymmetricMatch(other: Array<T>) {
    if (!Array.isArray(this.sample)) {
      throw new TypeError(
        `You must provide an array to ${this.toString()}, not '${typeof this
          .sample}'.`
      );
    }

    const result =
      this.sample.length === 0 ||
      (Array.isArray(other) &&
        this.sample.every((item) =>
          other.some((another) => equals(item, another, []))
        ));

    return this.inverse ? !result : result;
  }

  toString() {
    return `Array${this.inverse ? "Not" : ""}Containing`;
  }

  getExpectedType() {
    return "array";
  }
}

export class Any extends AsymmetricMatcher<any> {
  constructor(sample: unknown) {
    if (typeof sample === "undefined") {
      throw new TypeError(
        "any() expects to be passed a constructor function. " +
          "Please pass one or use anything() to match any object."
      );
    }
    super(sample);
  }

  fnNameFor(func: Function) {
    if (func.name) return func.name;

    const functionToString = Function.prototype.toString;

    const matches = functionToString
      .call(func)
      .match(/^(?:async)?\s*function\s*\*?\s*([\w$]+)\s*\(/);
    return matches ? matches[1] : "<anonymous>";
  }

  asymmetricMatch(other: unknown) {
    if (this.sample === String)
      return typeof other == "string" || other instanceof String;

    if (this.sample === Number)
      return typeof other == "number" || other instanceof Number;

    if (this.sample === Function)
      return typeof other == "function" || other instanceof Function;

    if (this.sample === Boolean)
      return typeof other == "boolean" || other instanceof Boolean;

    if (this.sample === BigInt)
      return typeof other == "bigint" || other instanceof BigInt;

    if (this.sample === Symbol)
      return typeof other == "symbol" || other instanceof Symbol;

    if (this.sample === Object) return typeof other == "object";

    return other instanceof this.sample;
  }

  toString() {
    return "Any";
  }

  getExpectedType() {
    if (this.sample === String) return "string";

    if (this.sample === Number) return "number";

    if (this.sample === Function) return "function";

    if (this.sample === Object) return "object";

    if (this.sample === Boolean) return "boolean";

    return this.fnNameFor(this.sample);
  }

  toAsymmetricMatcher() {
    return `Any<${this.fnNameFor(this.sample)}>`;
  }
}

export class StringMatching extends AsymmetricMatcher<RegExp> {
  constructor(sample: string | RegExp, inverse = false) {
    if (!isA("String", sample) && !isA("RegExp", sample))
      throw new Error("Expected is not a String or a RegExp");

    super(new RegExp(sample), inverse);
  }

  asymmetricMatch(other: string) {
    const result = isA("String", other) && this.sample.test(other);

    return this.inverse ? !result : result;
  }

  toString() {
    return `String${this.inverse ? "Not" : ""}Matching`;
  }

  getExpectedType() {
    return "string";
  }
}

class CloseTo extends AsymmetricMatcher<number> {
  private readonly precision: number;

  constructor(sample: number, precision = 2, inverse = false) {
    if (!isA("Number", sample)) throw new Error("Expected is not a Number");

    if (!isA("Number", precision)) throw new Error("Precision is not a Number");

    super(sample);
    this.inverse = inverse;
    this.precision = precision;
  }

  asymmetricMatch(other: number) {
    if (!isA("Number", other)) return false;

    let result = false;
    if (
      other === Number.POSITIVE_INFINITY &&
      this.sample === Number.POSITIVE_INFINITY
    ) {
      result = true; // Infinity - Infinity is NaN
    } else if (
      other === Number.NEGATIVE_INFINITY &&
      this.sample === Number.NEGATIVE_INFINITY
    ) {
      result = true; // -Infinity - -Infinity is NaN
    } else {
      result = Math.abs(this.sample - other) < 10 ** -this.precision / 2;
    }
    return this.inverse ? !result : result;
  }

  toString() {
    return `Number${this.inverse ? "Not" : ""}CloseTo`;
  }

  override getExpectedType() {
    return "number";
  }

  override toAsymmetricMatcher(): string {
    return [
      this.toString(),
      this.sample,
      `(${pluralize("digit", this.precision)})`,
    ].join(" ");
  }
}

export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => {
  utils.addMethod(chai.expect, "anything", () => new Anything());

  utils.addMethod(chai.expect, "any", (expected: unknown) => new Any(expected));

  utils.addMethod(
    chai.expect,
    "stringContaining",
    (expected: string) => new StringContaining(expected)
  );

  utils.addMethod(
    chai.expect,
    "objectContaining",
    (expected: any) => new ObjectContaining(expected)
  );

  utils.addMethod(
    chai.expect,
    "arrayContaining",
    <T = any>(expected: Array<T>) => new ArrayContaining<T>(expected)
  );

  utils.addMethod(
    chai.expect,
    "stringMatching",
    (expected: any) => new StringMatching(expected)
  );

  utils.addMethod(
    chai.expect,
    "closeTo",
    (expected: any, precision?: number) => new CloseTo(expected, precision)
  );

  // defineProperty does not work
  (chai.expect as any).not = {
    stringContaining: (expected: string) =>
      new StringContaining(expected, true),
    objectContaining: (expected: any) => new ObjectContaining(expected, true),
    arrayContaining: <T = unknown>(expected: Array<T>) =>
      new ArrayContaining<T>(expected, true),
    stringMatching: (expected: string | RegExp) =>
      new StringMatching(expected, true),
    closeTo: (expected: any, precision?: number) =>
      new CloseTo(expected, precision, true),
  };
};
