/* eslint-disable @typescript-eslint/prefer-promise-reject-errors */
/* eslint-disable @typescript-eslint/only-throw-error */
// TODO: fix, should only allow `Error` type

import { z } from 'zod';
import { logger } from '../../test/util';
import { AsyncResult, Result } from './result';

describe('util/result', () => {
  describe('Result', () => {
    describe('constructors', () => {
      it('ok result', () => {
        const res = Result.ok(42);
        expect(res).toEqual({
          res: {
            ok: true,
            val: 42,
          },
        });
      });

      it('error result', () => {
        const res = Result.err('oops');
        expect(res).toEqual({
          res: {
            ok: false,
            err: 'oops',
          },
        });
      });
    });

    describe('Wrapping', () => {
      it('wraps callback returning value', () => {
        const res = Result.wrap(() => 42);
        expect(res).toEqual(Result.ok(42));
      });

      it('handles throw in callback', () => {
        const res = Result.wrap(() => {
          throw 'oops';
        });
        expect(res).toEqual(Result.err('oops'));
      });

      it('wraps callback returning promise', () => {
        const res = Result.wrap(() => Promise.resolve(42));
        expect(res).toEqual(AsyncResult.ok(42));
      });

      it('wraps callback returning failed promise', () => {
        const err = new Error('unknown');
        const res = Result.wrap(() => Promise.reject(err));
        expect(res).toEqual(AsyncResult.err(err));
      });

      it('wraps nullable callback', () => {
        const res: Result<number, 'oops'> = Result.wrapNullable(
          (): number | null => 42,
          'oops',
        );
        expect(res).toEqual(Result.ok(42));
      });

      it('wraps nullable callback null', () => {
        const res = Result.wrapNullable(() => null, 'oops');
        expect(res).toEqual(Result.err('oops'));
      });

      it('wraps nullable callback undefined', () => {
        const res = Result.wrapNullable(() => undefined, 'oops');
        expect(res).toEqual(Result.err('oops'));
      });

      it('distincts between null and undefined callback results', () => {
        expect(Result.wrapNullable(() => null, 'null', 'undefined')).toEqual(
          Result.err('null'),
        );
        expect(
          Result.wrapNullable(() => undefined, 'null', 'undefined'),
        ).toEqual(Result.err('undefined'));
      });

      it('handles nullable callback error', () => {
        const res = Result.wrapNullable(() => {
          throw 'oops';
        }, 'nullable');
        expect(res).toEqual(Result.err('oops'));
      });

      it('wraps pure nullable value', () => {
        const res = Result.wrapNullable(42, 'oops');
        expect(res).toEqual(Result.ok(42));
      });

      it('wraps nullable value null', () => {
        const res = Result.wrapNullable(null, 'oops');
        expect(res).toEqual(Result.err('oops'));
      });

      it('wraps nullable value undefined', () => {
        const res = Result.wrapNullable(undefined, 'oops');
        expect(res).toEqual(Result.err('oops'));
      });

      it('wraps zod parse result', () => {
        const schema = z.string().transform((x) => x.toUpperCase());
        expect(Result.wrap(schema.safeParse('foo'))).toEqual(Result.ok('FOO'));
        expect(Result.wrap(schema.safeParse(42))).toMatchObject(
          Result.err({
            issues: [
              { code: 'invalid_type', expected: 'string', received: 'number' },
            ],
          }),
        );
      });
    });

    describe('Unwrapping', () => {
      it('unwraps successful value', () => {
        const res = Result.ok(42);
        expect(res.unwrap()).toEqual({
          ok: true,
          val: 42,
        });
      });

      it('unwraps error value', () => {
        const res = Result.err('oops');
        expect(res.unwrap()).toEqual({
          ok: false,
          err: 'oops',
        });
      });

      it('skips fallback for successful value', () => {
        const res: Result<number> = Result.ok(42);
        expect(res.unwrapOr(-1)).toBe(42);
      });

      it('uses fallback for error value', () => {
        const res: Result<number, string> = Result.err('oops');
        expect(res.unwrapOr(42)).toBe(42);
      });

      it('unwrapOr throws uncaught transform error', () => {
        const res = Result.ok(42);
        expect(() =>
          res
            .transform(() => {
              throw 'oops';
            })
            .unwrapOr(0),
        ).toThrow('oops');
      });

      it('unwrap throws uncaught transform error', () => {
        const res = Result.ok(42);
        expect(() =>
          res
            .transform(() => {
              throw 'oops';
            })
            .unwrap(),
        ).toThrow('oops');
      });

      it('returns ok-value for unwrapOrThrow', () => {
        const res = Result.ok(42);
        expect(res.unwrapOrThrow()).toBe(42);
      });

      it('throws error for unwrapOrThrow on error result', () => {
        const res = Result.err('oops');
        expect(() => res.unwrapOrThrow()).toThrow('oops');
      });

      it('unwrapOrNull returns value for ok-result', () => {
        const res = Result.ok(42);
        expect(res.unwrapOrNull()).toBe(42);
      });

      it('unwrapOrNull returns null for error result', () => {
        const res = Result.err('oops');
        expect(res.unwrapOrNull()).toBeNull();
      });

      it('unwrapOrNull throws uncaught transform error', () => {
        const res = Result.ok(42);
        expect(() =>
          res
            .transform(() => {
              throw 'oops';
            })
            .unwrapOrNull(),
        ).toThrow('oops');
      });
    });

    describe('Transforming', () => {
      it('transforms value to value', () => {
        const res = Result.ok('foo').transform((x) => x.toUpperCase());
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('transforms value to Result', () => {
        const res = Result.ok('foo').transform((x) =>
          Result.ok(x.toUpperCase()),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('skips transform for error Result', () => {
        const res: Result<number, string> = Result.err('oops');
        const fn = jest.fn((x: number) => x + 1);
        expect(res.transform(fn)).toEqual(Result.err('oops'));
        expect(fn).not.toHaveBeenCalled();
      });

      it('logs and returns error on transform failure', () => {
        const res = Result.ok('foo').transform(() => {
          throw 'oops';
        });
        expect(res).toEqual(Result._uncaught('oops'));
        expect(logger.logger.warn).toHaveBeenCalledWith(
          { err: 'oops' },
          'Result: unhandled transform error',
        );
      });

      it('automatically converts zod values', () => {
        const schema = z.string().transform((x) => x.toUpperCase());
        const res = Result.ok('foo').transform((x) => schema.safeParse(x));
        expect(res).toEqual(Result.ok('FOO'));
      });
    });

    describe('Catch', () => {
      it('bypasses ok result', () => {
        const res = Result.ok(42);
        expect(res.catch(() => Result.ok(0))).toEqual(Result.ok(42));
        expect(res.catch(() => Result.ok(0))).toBe(res);
      });

      it('bypasses uncaught transform errors', () => {
        const res = Result.ok(42).transform(() => {
          throw 'oops';
        });
        expect(res.catch(() => Result.ok(0))).toEqual(Result._uncaught('oops'));
        expect(res.catch(() => Result.ok(0))).toBe(res);
      });

      it('converts error to Result', () => {
        const error: Result<number, string> = Result.err<string>('oops');
        const result = error.catch((_err) => Result.ok<number>(42));
        expect(result).toEqual(Result.ok(42));
      });

      it('handles error thrown in catch function', () => {
        const result = Result.err<string>('oops').catch(() => {
          throw 'oops';
        });
        expect(result).toEqual(Result._uncaught('oops'));
      });
    });

    describe('Parsing', () => {
      it('parses Zod schema', () => {
        const schema = z
          .string()
          .transform((x) => x.toUpperCase())
          .nullish();

        expect(Result.parse('foo', schema)).toEqual(Result.ok('FOO'));

        expect(Result.parse(42, schema).unwrap()).toMatchObject({
          err: { issues: [{ message: 'Expected string, received number' }] },
        });

        expect(Result.parse(undefined, schema).unwrap()).toMatchObject({
          err: {
            issues: [
              {
                message: `Result can't accept nullish values, but input was parsed by Zod schema to undefined`,
              },
            ],
          },
        });

        expect(Result.parse(null, schema).unwrap()).toMatchObject({
          err: {
            issues: [
              {
                message: `Result can't accept nullish values, but input was parsed by Zod schema to null`,
              },
            ],
          },
        });
      });

      it('parses Zod schema by piping from Result', () => {
        const schema = z
          .string()
          .transform((x) => x.toUpperCase())
          .nullish();

        expect(Result.ok('foo').parse(schema)).toEqual(Result.ok('FOO'));

        expect(Result.ok(42).parse(schema).unwrap()).toMatchObject({
          err: { issues: [{ message: 'Expected string, received number' }] },
        });

        expect(Result.err('oops').parse(schema)).toEqual(Result.err('oops'));
      });
    });

    describe('Handlers', () => {
      it('supports value handlers', () => {
        const cb = jest.fn();
        Result.ok(42).onValue(cb);
        expect(cb).toHaveBeenCalledWith(42);
      });

      it('supports error handlers', () => {
        const cb = jest.fn();
        Result.err('oops').onError(cb);
        expect(cb).toHaveBeenCalledWith('oops');
      });

      it('handles error thrown in value handler', () => {
        const res = Result.ok(42).onValue(() => {
          throw 'oops';
        });
        expect(res).toEqual(Result._uncaught('oops'));
      });

      it('handles error thrown in error handler', () => {
        const res = Result.err('oops').onError(() => {
          throw 'oops';
        });
        expect(res).toEqual(Result._uncaught('oops'));
      });
    });
  });

  describe('AsyncResult', () => {
    describe('Wrapping', () => {
      it('wraps promise', async () => {
        const res: AsyncResult<number, string> = Result.wrap(
          Promise.resolve(42),
        );
        await expect(res).resolves.toEqual(Result.ok(42));
      });

      it('wraps Result promise', async () => {
        const res: AsyncResult<number, string> = Result.wrap(
          Promise.resolve(Result.ok(42)),
        );
        await expect(res).resolves.toEqual(Result.ok(42));
      });

      it('handles rejected promise', async () => {
        const res: AsyncResult<number, string> = Result.wrap(
          Promise.reject('oops'),
        );
        await expect(res).resolves.toEqual(Result.err('oops'));
      });

      it('wraps nullable promise', async () => {
        const res: AsyncResult<number, 'oops'> = Result.wrapNullable(
          Promise.resolve<number | null>(42),
          'oops',
        );
        await expect(res).resolves.toEqual(Result.ok(42));
      });

      it('wraps promise returning null', async () => {
        const res = Result.wrapNullable(Promise.resolve(null), 'oops');
        await expect(res).resolves.toEqual(Result.err('oops'));
      });

      it('wraps promise returning undefined', async () => {
        const res = Result.wrapNullable(Promise.resolve(undefined), 'oops');
        await expect(res).resolves.toEqual(Result.err('oops'));
      });

      it('distincts between null and undefined promise results', async () => {
        await expect(
          Result.wrapNullable(Promise.resolve(null), 'null', 'undefined'),
        ).resolves.toEqual(Result.err('null'));

        await expect(
          Result.wrapNullable(Promise.resolve(undefined), 'null', 'undefined'),
        ).resolves.toEqual(Result.err('undefined'));
      });

      it('handles rejected nullable promise', async () => {
        const res = Result.wrapNullable(Promise.reject('oops'), 'nullable');
        await expect(res).resolves.toEqual(Result.err('oops'));
      });
    });

    describe('Unwrapping', () => {
      it('unwraps successful AsyncResult', async () => {
        const res = Result.wrap(Promise.resolve(42));
        await expect(res.unwrap()).resolves.toEqual({
          ok: true,
          val: 42,
        });
      });

      it('unwraps error AsyncResult', async () => {
        const res = Result.wrap(Promise.reject('oops'));
        await expect(res.unwrap()).resolves.toEqual({
          ok: false,
          err: 'oops',
        });
      });

      it('skips fallback for successful AsyncResult', async () => {
        const res = Result.wrap(Promise.resolve(42));
        await expect(res.unwrapOr(0)).resolves.toBe(42);
      });

      it('uses fallback for error AsyncResult', async () => {
        const res = Result.wrap(Promise.reject('oops'));
        await expect(res.unwrapOr(42)).resolves.toBe(42);
      });

      it('returns ok-value for unwrapOrThrow', async () => {
        const res = Result.wrap(Promise.resolve(42));
        await expect(res.unwrapOrThrow()).resolves.toBe(42);
      });

      it('rejects for error for unwrapOrThrow', async () => {
        const res = Result.wrap(Promise.reject('oops'));
        await expect(res.unwrapOrThrow()).rejects.toBe('oops');
      });

      it('unwrapOrNull returns value for ok-result', async () => {
        const res = AsyncResult.ok(42);
        await expect(res.unwrapOrNull()).resolves.toBe(42);
      });

      it('unwrapOrNull returns null for error result', async () => {
        const res = AsyncResult.err('oops');
        await expect(res.unwrapOrNull()).resolves.toBeNull();
      });
    });

    describe('Transforming', () => {
      it('transforms AsyncResult to pure value', async () => {
        const res = await AsyncResult.ok('foo').transform((x) =>
          x.toUpperCase(),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('transforms AsyncResult to Result', async () => {
        const res = await AsyncResult.ok('foo').transform((x) =>
          Result.ok(x.toUpperCase()),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('transforms Result to AsyncResult', async () => {
        const res = await Result.ok('foo').transform((x) =>
          AsyncResult.ok(x.toUpperCase()),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('transforms AsyncResult to AsyncResult', async () => {
        const res = await AsyncResult.ok('foo').transform((x) =>
          AsyncResult.ok(x.toUpperCase()),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('skips transform for failed promises', async () => {
        const res = AsyncResult.err('oops');
        const fn = jest.fn((x: number) => x + 1);
        await expect(res.transform(fn)).resolves.toEqual(Result.err('oops'));
        expect(fn).not.toHaveBeenCalled();
      });

      it('asyncronously transforms successfull promise to value', async () => {
        const res = await AsyncResult.ok('foo').transform((x) =>
          Promise.resolve(x.toUpperCase()),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('asynchronously transforms successful AsyncResult to Result', async () => {
        const res = await AsyncResult.ok('foo').transform((x) =>
          Promise.resolve(Result.ok(x.toUpperCase())),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('asynchronously transforms value to value', async () => {
        const res = await Result.ok('foo').transform((x) =>
          Promise.resolve(x.toUpperCase()),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('asynchronously transforms value to Result', async () => {
        const res = await Result.ok('foo').transform((x) =>
          Promise.resolve(Result.ok(x.toUpperCase())),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('skips async transform for error Result', async () => {
        const input: Result<number, string> = Result.err('oops');
        const fn = jest.fn((x: number) => Promise.resolve(x + 1));
        const res = await input.transform(fn);
        expect(res).toEqual(Result.err('oops'));
        expect(fn).not.toHaveBeenCalled();
      });

      it('skips async transform for rejected promise', async () => {
        const res: AsyncResult<number, string> = AsyncResult.err('oops');
        const fn = jest.fn((x: number) => Promise.resolve(x + 1));
        await expect(res.transform(fn)).resolves.toEqual(Result.err('oops'));
        expect(fn).not.toHaveBeenCalled();
      });

      it('re-wraps error thrown via unwrapping in async transform', async () => {
        const res = await AsyncResult.ok(42)
          .transform(async (): Promise<number> => {
            await Promise.resolve();
            throw 'oops';
          })
          .transform((x) => x + 1);
        expect(res).toEqual(Result._uncaught('oops'));
      });

      it('handles error thrown on Result async transform', async () => {
        const res = Result.ok('foo');
        await expect(
          res.transform((_) => Promise.reject('oops')),
        ).resolves.toEqual(Result._uncaught('oops'));
        expect(logger.logger.warn).toHaveBeenCalledWith(
          { err: 'oops' },
          'Result: unhandled async transform error',
        );
      });

      it('handles error thrown on promise transform', async () => {
        const res = AsyncResult.ok('foo');
        await expect(
          res.transform(() => {
            throw 'bar';
          }),
        ).resolves.toEqual(Result._uncaught('bar'));
        expect(logger.logger.warn).toHaveBeenCalledWith(
          { err: 'bar' },
          'AsyncResult: unhandled transform error',
        );
      });

      it('handles error thrown on promise async transform', async () => {
        const res = AsyncResult.ok('foo');
        await expect(
          res.transform(() => Promise.reject('bar')),
        ).resolves.toEqual(Result._uncaught('bar'));
        expect(logger.logger.warn).toHaveBeenCalledWith(
          { err: 'bar' },
          'AsyncResult: unhandled async transform error',
        );
      });

      it('accumulates error types into union type during chained transform', async () => {
        const fn1 = (x: string): Result<string, string> =>
          Result.ok(x.toUpperCase());

        const fn2 = (x: string): Result<string[], number> =>
          Result.ok(x.split(''));

        const fn3 = (x: string[]): Result<string, boolean> =>
          Result.ok(x.join('-'));

        type Res = Result<string, string | number | boolean>;
        const res: Res = await AsyncResult.ok('foo')
          .transform(fn1)
          .transform(fn2)
          .transform(fn3);

        expect(res).toEqual(Result.ok('F-O-O'));
      });

      it('asynchronously transforms Result to zod values', async () => {
        const schema = z.string().transform((x) => x.toUpperCase());
        const res = await Result.ok('foo').transform((x) =>
          Promise.resolve(schema.safeParse(x)),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });

      it('transforms AsyncResult to zod values', async () => {
        const schema = z.string().transform((x) => x.toUpperCase());
        const res = await AsyncResult.ok('foo').transform((x) =>
          schema.safeParse(x),
        );
        expect(res).toEqual(Result.ok('FOO'));
      });
    });

    describe('Catch', () => {
      it('converts error to AsyncResult', async () => {
        const error: Result<number, string> = Result.err<string>('oops');
        const result = await error.catch(() => AsyncResult.ok(42));
        expect(result).toEqual(Result.ok(42));
      });

      it('converts error to Promise', async () => {
        const fallback = Promise.resolve(Result.ok(42));
        const error: Result<number, string> = Result.err<string>('oops');
        const result = await error.catch(() => fallback);
        expect(result).toEqual(Result.ok(42));
      });

      it('handles error thrown in Promise result', async () => {
        const fallback = Promise.reject('oops');
        const result = await Result.err<string>('oops').catch(() => fallback);
        expect(result).toEqual(Result._uncaught('oops'));
      });

      it('converts AsyncResult error to Result', async () => {
        const error: AsyncResult<number, string> =
          AsyncResult.err<string>('oops');
        const result = await error.catch(() => AsyncResult.ok<number>(42));
        expect(result).toEqual(Result.ok(42));
      });
    });
  });

  describe('Parsing', () => {
    it('parses Zod schema by piping from AsyncResult', async () => {
      const schema = z
        .string()
        .transform((x) => x.toUpperCase())
        .nullish();

      expect(await AsyncResult.ok('foo').parse(schema)).toEqual(
        Result.ok('FOO'),
      );

      expect(await AsyncResult.ok(42).parse(schema).unwrap()).toMatchObject({
        err: { issues: [{ message: 'Expected string, received number' }] },
      });
    });

    it('handles uncaught error thrown in the steps before parsing', async () => {
      const res = await AsyncResult.ok(42)
        .transform(async (): Promise<number> => {
          await Promise.resolve();
          throw 'oops';
        })
        .parse(z.number().transform((x) => x + 1));
      expect(res).toEqual(Result._uncaught('oops'));
    });
  });

  describe('Handlers', () => {
    it('supports value handlers', async () => {
      const cb = jest.fn();
      await AsyncResult.ok(42).onValue(cb);
      expect(cb).toHaveBeenCalledWith(42);
    });

    it('supports error handlers', async () => {
      const cb = jest.fn();
      await AsyncResult.err('oops').onError(cb);
      expect(cb).toHaveBeenCalledWith('oops');
    });

    it('handles error thrown in value handler', async () => {
      const res = await AsyncResult.ok(42).onValue(() => {
        throw 'oops';
      });
      expect(res).toEqual(Result._uncaught('oops'));
    });

    it('handles error thrown in error handler', async () => {
      const res = await AsyncResult.err('oops').onError(() => {
        throw 'oops';
      });
      expect(res).toEqual(Result._uncaught('oops'));
    });
  });
});