const REGEX_PAT = /[\/](.*)[\/](i|g|m|s)?/;

export class CoerceUtil {
  /**
   * Is a value a plain JS object, created using {}
   */
  static #isPlainObject(obj: unknown): obj is Record<string, unknown> {
    return typeof obj === 'object' // separate from primitives
      && obj !== undefined
      && obj !== null         // is obvious
      && obj.constructor === Object // separate instances (Array, DOM, ...)
      && Object.prototype.toString.call(obj) === '[object Object]'; // separate build-in like Math
  }

  /**
   * Create regex from string, including flags
   */
  static #toRegex(input: string | RegExp): RegExp {
    if (input instanceof RegExp) {
      return input;
    } else if (REGEX_PAT.test(input)) {
      const [, pat, mod] = input.match(REGEX_PAT) ?? [];
      return new RegExp(pat, mod);
    } else {
      return new RegExp(input);
    }
  }

  /**
   * Coerce an input of any type to the class provided
   * @param input Input value
   * @param type Class to coerce to (String, Boolean, Number, Date, RegEx, Object)
   * @param strict Should a failure to coerce throw an error?
   */
  static coerce(input: unknown, type: typeof String, strict?: boolean): string;
  static coerce(input: unknown, type: typeof BigInt, strict?: boolean): bigint;
  static coerce(input: unknown, type: typeof Number, strict?: boolean): number;
  static coerce(input: unknown, type: typeof Boolean, strict?: boolean): boolean;
  static coerce(input: unknown, type: typeof Date, strict?: boolean): Date;
  static coerce(input: unknown, type: typeof RegExp, strict?: boolean): RegExp;
  static coerce(input: unknown, type: Function, strict = true): unknown {
    // Do nothing
    if (input === null || input === undefined) {
      return input;
    } else if (!strict && type !== String && input === '') {
      return undefined; // treat empty string as undefined for non-strings in non-strict mode
    } else if (type && input instanceof type) {
      return input;
    }

    switch (type) {
      case Date: {
        const value = typeof input === 'number' || /^[-]?\d+$/.test(`${input}`) ?
          new Date(parseInt(`${input}`, 10)) : new Date(`${input}`);
        if (strict && Number.isNaN(value.getTime())) {
          throw new Error(`Invalid date value: ${input}`);
        }
        return value;
      }
      case Number: {
        const value = `${input}`.includes('.') ? parseFloat(`${input}`) : parseInt(`${input}`, 10);
        if (strict && Number.isNaN(value)) {
          throw new Error(`Invalid numeric value: ${input}`);
        }
        return value;
      }
      case BigInt: {
        try {
          return BigInt(typeof input === 'string' || typeof input === 'number' ? input : `${input}`);
        } catch {
          if (strict) {
            throw new Error(`Invalid numeric value: ${input}`);
          }
          return;
        }
      }
      case Boolean: {
        const match = `${input}`.match(/^((?<TRUE>true|yes|1|on)|false|no|off|0)$/i);
        if (strict && !match) {
          throw new Error(`Invalid boolean value: ${input}`);
        }
        return !!match?.groups?.TRUE;
      }
      case RegExp: {
        if (typeof input === 'string') {
          try {
            return this.#toRegex(input);
          } catch {
            if (strict) {
              throw new Error(`Invalid regex: ${input}`);
            } else {
              return;
            }
          }
        } else if (strict) {
          throw new Error('Invalid regex type');
        } else {
          return;
        }
      }
      case Object: {
        if (!strict || this.#isPlainObject(input)) {
          return input;
        } else {
          throw new Error('Invalid object type');
        }
      }
      case undefined:
      case String: return `${input}`;
    }
    throw new Error(`Unknown type ${type.name}`);
  }
}