import { describe, expect, it } from '@jest/globals';

import {
  asArray,
  asBoolean,
  asDate,
  asNonNegNumber,
  asNumber,
  asObject,
  asString,
  check,
  getFrom,
  isObject,
  optional,
  pickDefined,
  printValue,
  required,
} from '../src/types.js';

describe('types', () => {
  describe('pickDefined', () => {
    it('returns a copy of the original object', () => {
      const input = { foo: 23, bar: 'foo' };

      const result = pickDefined(input);

      expect(result).toEqual(input);
      expect(result).not.toBe(input);
    });

    it('removes all undefined values', () => {
      const input = { foo: 23, u1: undefined, bar: 42, u2: undefined };

      const result = pickDefined(input);

      expect(result).toEqual({ foo: 23, bar: 42 });
    });

    it('does not remove null and falsy values', () => {
      const input = { zero: 0, null: null, empty: '', false: false, undef: undefined };

      const result = pickDefined(input);

      expect(result).toEqual({ zero: 0, null: null, empty: '', false: false });
    });
  });

  describe('check', () => {
    it('returns value', () => {
      const input = 23;

      const result = check(input, 'foo');

      expect(result).toEqual(23);
    });

    it('applies given function', () => {
      const input = 23;

      const result = check(input, 'foo', (n) => (n as number) + 1);

      expect(result).toEqual(24);
    });

    it('throws if given function throws', () => {
      const input = 23;
      const bad = () => {
        throw new TypeError('bad value');
      };

      const fn = () => check(input, 'foo', bad);

      expect(fn).toThrowError('Invalid value for "foo": bad value');
    });

    it('merges nested error messages', () => {
      const input = 23;
      const bad = () => {
        throw new TypeError('bad value');
      };
      const nestedCheck = () => check(input, 'bar', bad);

      const fn = () => check(input, 'foo', nestedCheck);

      expect(fn).toThrowError('Invalid value for "foo.bar": bad value');
    });

    it('handles bracket notation in nested error messages', () => {
      const input = 23;
      const bad = () => {
        throw new TypeError('bad value');
      };
      const nestedCheck = () => check(input, '[0]', bad);

      const fn = () => check(input, 'foo', nestedCheck);

      expect(fn).toThrowError('Invalid value for "foo[0]": bad value');
    });

    it('throws for missing value', () => {
      const fn = () => check(undefined, 'foo', required());

      expect(fn).toThrowError('Missing value for "foo"');
    });
  });

  describe('getFrom', () => {
    it('gets value from object', () => {
      const input = { foo: 23, bar: 42 };

      const result = getFrom(input, 'foo');

      expect(result).toEqual(23);
    });

    it('applies given function', () => {
      const input = { foo: 23, bar: 42 };

      const result = getFrom(input, 'foo', (n) => (n as number) + 1);

      expect(result).toEqual(24);
    });

    it('throws if given function throws', () => {
      const input = { foo: 23, bar: 42 };
      const bad = () => {
        throw new TypeError('bad value');
      };

      const fn = () => getFrom(input, 'foo', bad);

      expect(fn).toThrowError('Invalid value for "foo": bad value');
    });
  });

  describe('optional', () => {
    const fn = (n) => `${n}`;

    it('returns function that delegates to given function', () => {
      expect(optional(fn)(23)).toEqual('23');
    });

    it('returns function that returns value if no function given', () => {
      expect(optional()(23)).toEqual(23);
    });

    it('returns function that returns undefined if input is undefined', () => {
      expect(optional(fn)(undefined)).toBeUndefined();
    });

    it('returns function that delegates to given function for falsy values', () => {
      expect(optional(fn)(null)).toEqual('null');
      expect(optional(fn)(false)).toEqual('false');
      expect(optional(fn)('')).toEqual('');
    });
  });

  describe('required', () => {
    const fn = (n) => `${n}`;

    it('returns function that delegates to given function', () => {
      expect(required(fn)(23)).toEqual('23');
    });

    it('returns function that returns value if no function given', () => {
      expect(required()(23)).toEqual(23);
    });

    it('returns function that throws if input is undefined', () => {
      const wrapped = required(fn);

      expect(() => wrapped(undefined)).toThrowError('Missing value');
    });

    it('returns function that delegates to given function for falsy values', () => {
      expect(required(fn)(null)).toEqual('null');
      expect(required(fn)(false)).toEqual('false');
      expect(required(fn)('')).toEqual('');
    });
  });

  describe('asBoolean', () => {
    it('returns boolean values', () => {
      expect(asBoolean(true)).toBe(true);
      expect(asBoolean(false)).toBe(false);
    });

    it('throws for other types', () => {
      expect(() => asBoolean(23)).toThrowError('Expected boolean, got: 23');
      expect(() => asBoolean(null)).toThrowError('Expected boolean, got: null');
    });
  });

  describe('asString', () => {
    it('returns strings', () => {
      expect(asString('foo')).toBe('foo');
      expect(asString('')).toBe('');
    });

    it('throws for other types', () => {
      expect(() => asString(23)).toThrowError('Expected string, got: 23');
      expect(() => asString(null)).toThrowError('Expected string, got: null');
    });
  });

  describe('asNumber', () => {
    it('returns numbers', () => {
      expect(asNumber(23)).toBe(23);
      expect(asNumber(-1.5)).toBe(-1.5);
    });

    it('throws for non-finite numbers', () => {
      expect(() => asNumber(NaN)).toThrowError('Expected number, got: NaN');
      expect(() => asNumber(Infinity)).toThrowError('Expected number, got: Infinity');
    });

    it('throws for other types', () => {
      expect(() => asNumber('23')).toThrowError("Expected number, got: '23'");
      expect(() => asNumber(null)).toThrowError('Expected number, got: null');
    });
  });

  describe('asNonNegNumber', () => {
    it('returns zero and positive numbers', () => {
      expect(asNonNegNumber(0)).toBe(0);
      expect(asNonNegNumber(23)).toBe(23);
    });

    it('throws for negative numbers', () => {
      expect(() => asNonNegNumber(-1)).toThrowError('Expected non-negative number, got: -1');
    });
  });

  describe('asDate', () => {
    it('returns date objects', () => {
      const date = new Date('2000-04-01T12:13:14.000Z');

      expect(asDate(date)).toBe(date);
    });

    it('throws for date strings', () => {
      expect(() => asDate('2000-04-01T12:13:14.000Z')).toThrowError(
        "Expected Date, got: '2000-04-01T12:13:14.000Z'"
      );
    });

    it('throws for other types', () => {
      expect(() => asDate(23)).toThrowError('Expected Date, got: 23');
      expect(() => asDate(null)).toThrowError('Expected Date, got: null');
    });
  });

  describe('asArray', () => {
    it('returns arrays', () => {
      expect(asArray([])).toEqual([]);
      expect(asArray(['foo'])).toEqual(['foo']);
    });

    it('throws for objects', () => {
      expect(() => asArray({})).toThrowError('Expected array, got: {}');
      expect(() => asArray(Uint8Array.of(1, 2, 3).buffer)).toThrowError(
        'Expected array, got: ArrayBuffer [1, 2, 3]'
      );
    });
  });

  describe('asObject', () => {
    it('returns objects', () => {
      expect(asObject({})).toEqual({});
      expect(asObject({ foo: 23 })).toEqual({ foo: 23 });
    });

    it('throws for other types', () => {
      expect(() => asObject(null)).toThrowError('Expected object, got: null');
      expect(() => asObject([])).toThrowError('Expected object, got: []');
      expect(() => asObject(new ArrayBuffer(3))).toThrowError(
        'Expected object, got: ArrayBuffer [0, 0, 0]'
      );
    });
  });

  describe('isObject', () => {
    it('returns true for objects', () => {
      expect(isObject({})).toBe(true);
      expect(isObject({ foo: 23 })).toBe(true);
    });

    it('returns false for other types', () => {
      expect(isObject(null)).toBe(false); // typeof null === 'object'
      expect(isObject(0)).toBe(false);
      expect(isObject(Infinity)).toBe(false);
      expect(isObject(NaN)).toBe(false);
      expect(isObject('[object Object]')).toBe(false);
      expect(isObject([])).toBe(false); // typeof [] === 'object'
      expect(isObject([{}])).toBe(false); // [{}].toString() === '[object Object]'
      expect(isObject(new ArrayBuffer(3))).toBe(false);
      expect(isObject(new Date())).toBe(false);
    });
  });

  describe('printValue', () => {
    it('prints strings', () => {
      expect(printValue('')).toEqual("''");
      expect(printValue('foo')).toEqual("'foo'");
    });

    it('prints numbers', () => {
      expect(printValue(0)).toEqual('0');
      expect(printValue(23)).toEqual('23');
    });

    it('prints boolean values', () => {
      expect(printValue(true)).toEqual('true');
      expect(printValue(false)).toEqual('false');
    });

    it('prints Date objects', () => {
      expect(printValue(new Date('2001-02-03T04:05:06.789Z'))).toEqual(
        'Date 2001-02-03T04:05:06.789Z'
      );
    });

    it('prints functions', () => {
      expect(printValue((x) => x)).toEqual('anonymous function');
      expect(printValue(async (x) => x)).toEqual('anonymous function');
      expect(printValue(printValue)).toEqual('function printValue');
    });

    it('prints ArrayBuffers', () => {
      expect(printValue(Uint8Array.of(1, 2, 3).buffer)).toEqual('ArrayBuffer [1, 2, 3]');
    });

    it('prints typed arrays', () => {
      expect(printValue(Uint8Array.of(1, 2, 3))).toEqual('Uint8Array [1, 2, 3]');
      expect(printValue(Int8Array.of(-1, -2, -3))).toEqual('Int8Array [255, 254, 253]');
    });

    it('prints arrays', () => {
      expect(printValue([])).toEqual('[]');
      expect(printValue([1, 2, 3])).toEqual('[1, 2, 3]');
      expect(printValue(['a', 'b', 'c'])).toEqual("['a', 'b', 'c']");
    });

    it('prints nested arrays', () => {
      expect(printValue([[]])).toEqual('[[]]');
      expect(printValue([1, 2, [3, 4]])).toEqual('[1, 2, [3, 4]]');
    });

    it('prints only first 8 elements of arrays', () => {
      const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

      expect(printValue(arr)).toEqual('[0, 1, 2, 3, 4, 5, 6, 7, …]');
    });

    it('handles circular references in arrays', () => {
      const arr = [0, {}];
      arr.push(arr);

      expect(printValue(arr)).toEqual('[0, {}, recursive ref]');
    });

    it('prints objects', () => {
      expect(printValue({})).toEqual('{}');
      expect(printValue({ a: 1, b: 2 })).toEqual('{a: 1, b: 2}');
      expect(printValue({ a: 'a', b: 'b' })).toEqual("{a: 'a', b: 'b'}");
    });

    it('prints nested objects', () => {
      expect(printValue({ a: 1, b: { c: 2 } })).toEqual('{a: 1, b: {c: 2}}');
    });

    it('prints only first 8 entries of objects', () => {
      const obj = { a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, g: 6, h: 7, i: 8, j: 9 };

      expect(printValue(obj)).toEqual('{a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, g: 6, h: 7, …}');
    });

    it('handles circular references in objects', () => {
      const obj = { a: 0, b: {} };
      obj.b = obj;

      expect(printValue(obj)).toEqual('{a: 0, b: recursive ref}');
    });

    it('handles deep circular references in objects', () => {
      const obj = { a: 0, b: [1, 2, {}] };
      obj.b.push({ obj });

      expect(printValue(obj)).toEqual('{a: 0, b: [1, 2, {}, {obj: recursive ref}]}');
    });
  });
});
