import { Entity, schema } from '@data-client/endpoint';
import { useController } from '@data-client/react';
import { useSuspense } from '@data-client/react';
import { CacheProvider } from '@data-client/react';
import { CoolerArticle, CoolerArticleResource } from '__tests__/new';
import nock from 'nock';

import { makeRenderDataClient } from '../../../test';
import RestEndpoint, {
  Defaults,
  RestEndpointConstructorOptions,
  RestGenerics,
} from '../RestEndpoint';
import {
  payload,
  createPayload,
  users,
  nested,
  moreNested,
  paginatedFirstPage,
  paginatedSecondPage,
} from '../test-fixtures';

export class User extends Entity {
  readonly id: number | undefined = undefined;
  readonly username: string = '';
  readonly email: string = '';
  readonly isAdmin: boolean = false;
}
const getUser = new RestEndpoint({
  path: 'http\\://test.com/user/:id',
  name: 'User.get',
  schema: User,
  method: 'GET',
  searchParams: {} as { extra?: string },
});
export class PaginatedArticle extends Entity {
  readonly id: number | undefined = undefined;
  readonly title: string = '';
  readonly content: string = '';
  readonly author: number | null = null;
  readonly tags: string[] = [];

  static schema = {
    author: User,
  };
}
const getArticleList = new RestEndpoint({
  urlPrefix: 'http://test.com',
  path: '/article-paginated',
  schema: {
    nextPage: '',
    data: { results: new schema.Collection([PaginatedArticle]) },
  },
  method: 'GET',
});
const getNextPage = getArticleList.paginated(
  (v: { cursor: string | number }) => [],
);

const getArticleList2 = new RestEndpoint({
  urlPrefix: 'http://test.com/article-paginated/',
  path: ':group',
  name: 'get',
  schema: {
    nextPage: '',
    data: { results: new schema.Collection([PaginatedArticle]) },
  },
  method: 'GET',
});
const getNextPage2 = getArticleList2.paginated(
  ({
    cursor,
    ...rest
  }: {
    cursor: string | number;
    group: string | number;
  }) => [rest],
);

const getArticleList3 = new RestEndpoint({
  urlPrefix: 'http://test.com',
  path: '/article-paginated',
  schema: {
    nextPage: '',
    data: { results: new schema.Collection([PaginatedArticle]) },
  },
  method: 'GET',
  searchParams: {} as { group: string | number },
  paginationField: 'cursor',
}).extend({
  dataExpiryLength: 10000,
});
const getNextPage3 = getArticleList3.getPage;

// type tests
() => {
  const base = new RestEndpoint({
    urlPrefix: 'http://test.com',
    path: '/article-paginated',
    schema: {
      nextPage: '',
      data: { results: new schema.Collection([PaginatedArticle]) },
    },
    method: 'GET',
  });
  // @ts-expect-error
  () => base.getPage();
  () =>
    // @ts-expect-error
    base
      .extend({
        path: '',
        dataExpiryLength: 10000,
      })
      .getPage();
  const a = new RestEndpoint({
    urlPrefix: 'http://test.com',
    path: '/article-paginated',
    schema: {
      nextPage: '',
      data: { results: new schema.Collection([PaginatedArticle]) },
    },
    method: 'GET',
    searchParams: {} as { group: string | number },
  }).extend({
    path: ':blob',
    searchParams: {} as { isAdmin?: boolean },
    method: 'GET',
    paginationField: 'cursor',
  });
  a.getPage({ cursor: 'hi', blob: 'ho' });
  // @ts-expect-error
  a.getPage({ blob: 'ho' });
};

describe('RestEndpoint', () => {
  const renderDataClient: ReturnType<typeof makeRenderDataClient> =
    makeRenderDataClient(CacheProvider);
  let mynock: nock.Scope;

  beforeEach(() => {
    nock(/.*/)
      .persist()
      .defaultReplyHeaders({
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'application/json',
      })
      .options(/.*/)
      .reply(200)
      .get(`/article-cooler/${payload.id}`)
      .reply(200, payload)
      .delete(`/article-cooler/${payload.id}`)
      .reply(204, '')
      .delete(`/article/${payload.id}`)
      .reply(200, {})
      .get(`/article-cooler/0`)
      .reply(403, {})
      .get(`/article-cooler/666`)
      .reply(200, '')
      .get(`/article-cooler`)
      .reply(200, nested)
      .post(`/article-cooler`)
      .reply(200, createPayload)
      .get(`/user`)
      .reply(200, users);
    mynock = nock(/.*/).defaultReplyHeaders({
      'Access-Control-Allow-Origin': '*',
      'Content-Type': 'application/json',
    });
  });

  afterEach(() => {
    nock.cleanAll();
  });

  it('testKey should match keys', () => {
    expect(getArticleList.testKey(getArticleList.key())).toBeTruthy();
    expect(
      getUser.testKey(getUser.key({ id: '100', extra: '345' })),
    ).toBeTruthy();
    expect(getUser.testKey(getUser.key({ id: 'xxx?*' }))).toBeTruthy();
  });

  it('should assign members', () => {
    expect(getUser.path).toBe('http\\://test.com/user/:id');
    expect(getUser.sideEffect).toBe(undefined);
    expect(getUser.method).toBe('GET');

    // @ts-expect-error
    () => getUser.notassigned;
    // @ts-expect-error
    const a: true = getUser.sideEffect;
    // @ts-expect-error
    const b: 'POST' = getUser.method;
    // @ts-expect-error
    ((m: 'POST') => {})(getUser.method);

    const updateUser = new RestEndpoint({
      path: 'http\\://test.com/user/:id',
      name: 'update',
      schema: User,
      method: 'POST',
    });
    expect(updateUser.sideEffect).toBe(true);
    // @ts-expect-error
    const y: undefined = updateUser.sideEffect;
  });

  it('only optional path means the arg is not required', () => {
    const ep = new RestEndpoint({ path: '/users/:id?/:group?' });
    const epbody = new RestEndpoint({
      path: '/users/:id?/:group?',
      body: { title: '' },
      method: 'POST',
    });
    () => ep();
    () => ep({ id: 5 });
    () => ep({ group: 5 });
    () => ep({ id: 5, group: 5 });
    () => epbody({ title: 'hi' });
    () => epbody({ id: 5 }, { title: 'hi' });
    () => epbody({ group: 5 }, { title: 'hi' });
    () => epbody({ id: 5, group: 5 }, { title: 'hi' });
    // @ts-expect-error
    () => epbody({ title: 'hi' }, { title: 'hi' });
  });

  it('should allow sideEffect overrides', () => {
    const weirdGetUser = new RestEndpoint({
      path: 'http\\://test.com/user/:id',
      name: 'getter',
      schema: User,
      method: 'POST',
      sideEffect: undefined,
    });

    expect(weirdGetUser.sideEffect).toBe(undefined);
    const a: undefined = weirdGetUser.sideEffect;
    // @ts-expect-error
    const y: true = weirdGetUser.sideEffect;
  });

  it('should handle simple urls', () => {
    expect(getUser.url({ id: '5' })).toBe('http://test.com/user/5');
    expect(getUser.url({ id: '100' })).toBe('http://test.com/user/100');

    // @ts-expect-error
    () => getUser.url({ sdf: '5' });
  });

  it('should handle multiarg urls', () => {
    const getMyUser = new RestEndpoint({
      path: 'http\\://test.com/groups/:group/users/:id',
      schema: User,
      method: 'GET',
      extra: 5,
    });

    expect(getMyUser.url({ group: 'big', id: '5' })).toBe(
      'http://test.com/groups/big/users/5',
    );
    expect(getMyUser.url({ group: 'big', id: '100' })).toBe(
      'http://test.com/groups/big/users/100',
    );

    // missing required
    expect(() =>
      // @ts-expect-error
      getMyUser.url({ id: '5' }),
    ).toThrow();
    // extra fields
    () =>
      getMyUser.url({
        group: 'mygroup',
        id: '5',
        // @ts-expect-error
        notexisting: 'hi',
      });

    // @ts-expect-error
    () => useSuspense(getMyUser, { id: '5' });
    // @ts-expect-error
    () => useSuspense(getMyUser);
    () => useSuspense(getMyUser, { group: 'yay', id: '5' });
  });

  it('should automatically name methods', () => {
    expect(getUser.name).toBe('User.get');
    expect(getArticleList.name).toMatchInlineSnapshot(
      `"http://test.com/article-paginated"`,
    );
    expect(
      getArticleList.extend({ path: '/:something' }).name,
    ).toMatchInlineSnapshot(`"http://test.com/:something"`);
  });

  it('should update on get for a paginated resource', async () => {
    mynock.get(`/article-paginated`).reply(200, paginatedFirstPage);
    mynock.get(`/article-paginated?cursor=2`).reply(200, paginatedSecondPage);

    const { result, waitForNextUpdate, controller } = renderDataClient(() => {
      const { fetch } = useController();
      const {
        data: { results: articles },
        nextPage,
      } = useSuspense(getArticleList);
      return { articles, nextPage, fetch };
    });
    await waitForNextUpdate();
    // @ts-expect-error
    () => controller.fetch(getNextPage);
    // @ts-expect-error
    () => controller.fetch(getNextPage, { fake: 5 });
    expect(result.current.nextPage).toEqual(paginatedFirstPage.nextPage);
    await controller.fetch(getNextPage, {
      cursor: result.current.nextPage,
    });
    expect(result.current.articles.map(({ id }) => id)).toEqual([5, 3, 7, 8]);
    expect(result.current.nextPage).toBeUndefined();
  });

  it('should update on get for a paginated resource with parameter in path', async () => {
    mynock.get(`/article-paginated/happy`).reply(200, paginatedFirstPage);
    mynock
      .get(`/article-paginated/happy?cursor=2`)
      .reply(200, paginatedSecondPage);

    const { result, waitForNextUpdate, controller } = renderDataClient(() => {
      const {
        data: { results: articles },
        nextPage,
      } = useSuspense(getArticleList2, {
        group: 'happy',
      });
      return { articles, nextPage };
    });
    await waitForNextUpdate();
    // @ts-expect-error
    () => controller.fetch(getNextPage2);
    // @ts-expect-error
    () => controller.fetch(getNextPage2, { fake: 5 });
    // @ts-expect-error
    () => controller.fetch(getNextPage2, { group: 'happy' });
    // @ts-expect-error
    () => controller.fetch(getNextPage2, { cursor: 2 });
    await controller.fetch(getNextPage2, {
      group: 'happy',
      cursor: 2,
    });
    expect(result.current.articles.map(({ id }) => id)).toEqual([5, 3, 7, 8]);
  });

  it('push: should extend name of parent endpoint', () => {
    expect(getArticleList3.push.name).toMatchSnapshot();
    expect(getArticleList3.push.name).toBe(getArticleList3.unshift.name);
  });

  it('unshift: should extend name of parent endpoint', () => {
    expect(getArticleList3.unshift.name).toMatchSnapshot();
  });

  // TODO: but we need a Values collection
  // it('assign: should extend name of parent endpoint', () => {
  //   expect(getArticleList3.assign.name).toMatchSnapshot();
  // });

  it('getPage: should extend name of parent endpoint', () => {
    expect(getNextPage3.name).toMatchSnapshot();
  });

  it('getPage: should update on get for a paginated resource with parameter in path', async () => {
    mynock.get(`/article-paginated?group=happy`).reply(200, paginatedFirstPage);
    mynock
      .get(`/article-paginated?cursor=2&group=happy`)
      .reply(200, paginatedSecondPage);

    const { result, waitForNextUpdate, controller } = renderDataClient(() => {
      const {
        data: { results: articles },
        nextPage,
      } = useSuspense(getArticleList3, {
        group: 'happy',
      });
      return { articles, nextPage };
    });
    await waitForNextUpdate();
    // @ts-expect-error
    () => controller.fetch(getNextPage3);
    // @ts-expect-error
    () => controller.fetch(getNextPage3, { fake: 5 });
    // @ts-expect-error
    () => controller.fetch(getNextPage3, { group: 'happy' });
    // @ts-expect-error
    () => controller.fetch(getNextPage3, { cursor: 2 });
    await controller.fetch(getNextPage3, {
      group: 'happy',
      cursor: 2,
    });
    expect(result.current.articles.map(({ id }) => id)).toEqual([5, 3, 7, 8]);
  });

  it('should deduplicate results', async () => {
    mynock.get(`/article-paginated`).reply(200, paginatedFirstPage);
    mynock.get(`/article-paginated?cursor=2`).reply(200, {
      ...paginatedSecondPage,
      results: [nested[nested.length - 1], ...moreNested],
    });

    const { result, waitForNextUpdate } = renderDataClient(() => {
      const { fetch } = useController();
      const {
        data: { results: articles },
        nextPage,
      } = useSuspense(getArticleList);
      return { articles, nextPage, fetch };
    });
    await waitForNextUpdate();
    await result.current.fetch(getNextPage, {
      cursor: 2,
    });
    //TODO: Why is this broken? expect(result.current.articles.map(({ id }) => id)).toEqual([5, 3, 7, 8]);
  });

  it('should not deep-merge deeply defined entities', async () => {
    interface Complex {
      firstvalue: number;
      secondthing: {
        arg?: number;
        other?: string;
      };
    }
    class ComplexEntity extends Entity {
      readonly id: string = '';
      readonly complexThing?: Complex = undefined;
      readonly extra: string = '';

      pk() {
        return this.id;
      }
    }
    const getComplex = new RestEndpoint({
      path: '/complex-thing/:id',
      schema: ComplexEntity,
      method: 'GET',
    });

    const firstResponse = {
      id: '5',
      complexThing: {
        firstvalue: 233,
        secondthing: { arg: 88 },
      },
      extra: 'hi',
    };
    mynock.get(`/complex-thing/5`).reply(200, firstResponse);

    const { result, waitForNextUpdate } = renderDataClient(() => {
      const { fetch } = useController();
      const article = useSuspense(getComplex, { id: '5' });
      return { article, fetch };
    });
    await waitForNextUpdate();
    expect(result.current.article).toEqual(firstResponse);

    const secondResponse = {
      id: '5',
      complexThing: {
        firstvalue: 5,
        secondthing: { other: 'hi' },
      },
    };

    mynock.get(`/complex-thing/5`).reply(200, secondResponse);
    await result.current.fetch(getComplex, {
      id: '5',
    });
    expect(result.current.article).toEqual({ ...secondResponse, extra: 'hi' });
  });

  it('overriding methods should work', async () => {
    mynock.put(`/user/5`).reply(200, (uri, body: any) => ({
      id: 5,
      username: 'bob',
      ...body,
    }));
    const updateUser = new RestEndpoint({
      method: 'PUT',
      path: 'http\\://test.com/user/:id',
      name: 'get',
      schema: User,
      getRequestInit(body) {
        if (body && isPojo(body)) {
          return RestEndpoint.prototype.getRequestInit.call(this, {
            ...body,
            email: 'always@always.com',
          });
        }
        return RestEndpoint.prototype.getRequestInit.call(this, body);
      },
    });
    const response = await updateUser(
      { id: 5 },
      { username: 'micky', email: 'micky@gmail.com' },
    );
    expect(response).toMatchInlineSnapshot(`
      {
        "email": "always@always.com",
        "id": 5,
        "username": "micky",
      }
    `);
  });

  describe('class extensions', () => {
    class DefaultUser extends User {
      defaultUserExtra = 'yay';
    }

    class MyEndpoint<O extends RestGenerics = any> extends RestEndpoint<
      Defaults<O, { schema: DefaultUser }>
    > {
      constructor(options: Readonly<RestEndpointConstructorOptions<O> & O>) {
        super({ schema: DefaultUser, ...options } as any);
      }

      parseResponse(response: Response): Promise<any> {
        return super.parseResponse(response);
      }

      getRequestInit(body: any) {
        if (isPojo(body)) {
          return super.getRequestInit({ ...body, email: 'always@always.com' });
        }
        return super.getRequestInit(body);
      }

      additional = 5;
    }

    it('should work with constructor', async () => {
      mynock.put('/user/5').reply(200, (uri, body: any) => ({
        id: 5,
        username: 'bob',
        ...body,
      }));

      const updateUser = new MyEndpoint({
        method: 'PUT',
        path: 'http\\://test.com/user/:id',
        name: 'update',
        schema: User,
      });
      const response = await updateUser(
        { id: 5 },
        { username: 'micky', email: 'micky@gmail.com' },
      );
      expect(response).toMatchInlineSnapshot(`
        {
          "email": "always@always.com",
          "id": 5,
          "username": "micky",
        }
      `);
      expect(updateUser.additional).toBe(5);
    });

    it('setting body in extend should work', async () => {
      mynock.put('/charmer/5').reply(200, (uri, body: any) => ({
        id: 5,
        username: 'bob',
        ...body,
      }));

      const updateUser = new MyEndpoint({
        method: 'PUT',
        path: 'http\\://test.com/user/:id',
        name: 'update',
        schema: User,
      }).extend({
        body: 5,
        path: 'http\\://test.com/charmer/:charm',
      });
      () => {
        // test type widening
        const second = updateUser.extend({ body: { body: '' } });
        second({ charm: 5 }, { body: 'hi' });
      };
      const response = await updateUser(
        { charm: 5 },
        // @ts-expect-error
        { username: 'micky', email: 'micky@gmail.com' },
      );
      () => updateUser({ charm: 5 }, 5);
      expect(response).toMatchInlineSnapshot(`
        {
          "email": "always@always.com",
          "id": 5,
          "username": "micky",
        }
      `);
      expect(updateUser.additional).toBe(5);
      const nobody = updateUser.extend({
        path: 'http\\://test.com/user/:charm',
        body: undefined,
      });
      () => nobody({ charm: 5 });
      // @ts-expect-error
      () => nobody({ id: 5 });
    });

    it('setting body in extend should work without path', async () => {
      mynock.put('/user/5').reply(200, (uri, body: any) => ({
        id: 5,
        username: 'bob',
        ...body,
      }));

      const updateUser = new MyEndpoint({
        method: 'PUT',
        path: 'http\\://test.com/user/:id',
        name: 'update',
        schema: User,
      }).extend({
        body: 5,
      });
      const response = await updateUser(
        { id: 5 },
        // @ts-expect-error
        { username: 'micky', email: 'micky@gmail.com' },
      );
      () => updateUser({ id: 5 }, 5);
      expect(response).toMatchInlineSnapshot(`
        {
          "email": "always@always.com",
          "id": 5,
          "username": "micky",
        }
      `);
      expect(updateUser.additional).toBe(5);
      const nobody = updateUser.extend({
        path: 'http\\://test.com/user/:charm',
        body: undefined,
      });
      () => nobody({ charm: 5 });
      // @ts-expect-error
      () => nobody({ id: 5 });

      const updateUser2 = new MyEndpoint({
        method: 'PUT',
        path: 'http\\://test.com/user/:id',
        name: 'update',
        schema: User,
      }).extend({
        searchParams: {} as { isAdmin: boolean },
      });
      () =>
        updateUser2(
          { id: 5, isAdmin: true },
          { username: 'micky', email: 'micky@gmail.com' },
        );
      () =>
        // @ts-expect-error
        updateUser2({ id: 5 }, { username: 'micky', email: 'micky@gmail.com' });
      () =>
        updateUser2(
          // @ts-expect-error
          { isAdmin: true },
          { username: 'micky', email: 'micky@gmail.com' },
        );

      const updateBasic = new MyEndpoint({
        method: 'PUT',
        path: 'http\\://test.com/user/:id',
        name: 'update',
        schema: User,
      });
      () =>
        updateBasic({ id: 5 }, { username: 'micky', email: 'micky@gmail.com' });
      () =>
        updateBasic(
          // @ts-expect-error
          { id: 5, isAdmin: true },
          { username: 'micky', email: 'micky@gmail.com' },
        );
    });

    it('should work with default schema in class definition', async () => {
      mynock.get('/user/5').reply(200, (uri, body: any) => ({
        id: 5,
        username: 'bob',
        email: 'bob@gmail.com',
      }));

      const getUser = new MyEndpoint({
        path: 'http\\://test.com/user/:id',
        name: 'update',
      });
      const { result, waitForNextUpdate } = renderDataClient(() => {
        return useSuspense(getUser, { id: 5 });
      });
      await waitForNextUpdate();

      expect(result.current.username).toBe('bob');
      // @ts-expect-error
      expect(result.current.sdfsd).toBeUndefined();
      expect(result.current.defaultUserExtra).toBe('yay');
      expect(result.current.email).toBe('bob@gmail.com');
    });

    it('should work with default path in class definition', async () => {
      mynock.get('/user/5').reply(200, (uri, body: any) => ({
        id: 5,
        username: 'bob',
        email: 'bob@gmail.com',
      }));
      // this seems like a less common use case; so we're fine with it being annoying
      class UserEndpoint<
        O extends Partial<RestGenerics> = {
          schema: DefaultUser;
          path: 'http\\://test.com/user/:id';
        },
      > extends MyEndpoint<
        Defaults<O, { schema: DefaultUser; path: 'http\\://test.com/user/:id' }>
      > {
        constructor({
          path = 'http\\://test.com/user/:id',
          ...options
        }: Readonly<O & { name: string }>) {
          super({ path, ...options } as any);
        }
      }

      const getUser = new UserEndpoint({
        method: 'GET',
        name: 'update',
      });
      const { result, waitForNextUpdate } = renderDataClient(() => {
        return useSuspense(getUser, { id: 5 });
      });
      await waitForNextUpdate();

      expect(result.current.username).toBe('bob');
      // @ts-expect-error
      expect(result.current.sdfsd).toBeUndefined();
      expect(result.current.defaultUserExtra).toBe('yay');
      expect(result.current.email).toBe('bob@gmail.com');

      // @ts-expect-error
      () => getUser({ group: 5 });
      // @ts-expect-error
      () => useSuspense(getUser, { group: 5 });
      // @ts-expect-error
      () => useSuspense(getUser);
    });

    it('update should work with extends', async () => {
      mynock.put('/6/user/5').reply(200, (uri, body: any) => ({
        id: 5,
        username: 'charles',
        ...body,
      }));

      const updateUser = new MyEndpoint({
        method: 'PUT',
        path: 'http\\://test.com/user/:id',
        name: 'update',
        schema: User,
      }).extend({
        path: 'http\\://test.com/:group/user/:id',
        body: 0 as Partial<User>,
      });
      expect(updateUser.additional).toBe(5);
      expect(updateUser.method).toBe('PUT');
      const response = await updateUser(
        { group: '6', id: 5 },
        { email: 'micky@gmail.com' },
      );
      expect(response).toMatchInlineSnapshot(`
        {
          "email": "always@always.com",
          "id": 5,
          "username": "charles",
        }
      `);
    });

    it('get should work with extends', async () => {
      mynock.get('/6/user/5').reply(200, (uri, body: any) => ({
        id: 5,
        username2: 'charles',
        ...body,
      }));
      class User2 extends Entity {
        readonly id: number | undefined = undefined;
        readonly username2: string = '';
        readonly email: string = '';
        readonly isAdmin: boolean = false;
      }

      const getUserBase = new MyEndpoint({
        method: 'GET',
        path: 'http\\://test.com/user/:id',
        name: 'getuser',
        schema: User,
      });
      const getUser = getUserBase.extend({
        path: 'http\\://test.com/:group/user/:id',
        schema: User2,
      });
      getUserBase.body;
      expect(getUserBase.name).toBe('getuser');
      expect(getUserBase.extend({ method: 'GET' }).name).toBe('getuser');
      expect(getUser.name).toBe('getuser');
      expect(getUser.additional).toBe(5);
      expect(getUser.method).toBe('GET');
      const user = await getUser({ group: '6', id: 5 });
      expect(user.username2).toBe('charles');
      () => {
        const a = useSuspense(getUser, { group: '6', id: 5 });
        // @ts-expect-error
        a.username;
      };
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
      expect(user.username).toBeUndefined();
      expect(user).toMatchInlineSnapshot(`
        {
          "id": 5,
          "username2": "charles",
        }
      `);

      const newBody = getUser
        .extend({
          body: {} as { title: string },
          dataExpiryLength: 0,
          method: 'POST',
        })
        .extend({ dataExpiryLength: 5 });
      () => newBody({ group: 'hi', id: 'what' }, { title: 'cool' });
      // @ts-expect-error
      () => newBody({ id: 'what' }, { title: 'cool' });
      // @ts-expect-error
      () => newBody({ title: 'cool' });
      // @ts-expect-error
      () => newBody({ group: 'hi', id: 'what' });
      // @ts-expect-error
      () => newBody({ group: 'hi', id: 'what' }, { sdfsd: 'cool' });

      const bodyNoPath = newBody.extend({
        path: '/',
      });
      const bodyNoParams = bodyNoPath.extend({
        body: {} as { happy: string },
      });
      () => bodyNoParams({ happy: 'cool' });
      // @ts-expect-error
      () => bodyNoParams({ group: 'hi', id: 'what' }, { happy: 'cool' });
      // @ts-expect-error
      () => bodyNoParams({ group: 'hi', id: 'what' }, { title: 'cool' });
      // @ts-expect-error
      () => bodyNoParams({ sdfd: 'cool' });

      const searchParams = getUser.extend({
        path: 'http\\://test.com/:group/user/:id',
        searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' },
      });
      () => searchParams({ group: 'hi', id: 'what', sort: 'asc' });
      () =>
        searchParams({ group: 'hi', id: 'what', sort: 'asc', isAdmin: true });
      // @ts-expect-error
      () => searchParams({ group: 'hi', id: 'what', sort: 'abc' });
      // @ts-expect-error
      () => searchParams({ group: 'hi', id: 'what' });
      // @ts-expect-error
      () => searchParams.url({ group: 'hi', id: 'what' });
      expect(
        searchParams.url({
          group: 'hi',
          id: 'what',
          sort: 'desc',
          isAdmin: true,
        }),
      ).toMatchInlineSnapshot(
        `"http://test.com/hi/user/what?isAdmin=true&sort=desc"`,
      );

      const searchParams2 = searchParams.extend({
        searchParams: {} as { bigger: boolean },
      });
      () => searchParams2({ group: 'hi', id: 'what', bigger: true });
      () => searchParams2({ group: 'hi', id: 'what', bigger: false });
      // @ts-expect-error
      () => searchParams2({ group: 'hi', id: 'what', bigger: 5 });
      // @ts-expect-error
      () => searchParams2({ group: 'hi', id: 'what', sort: 'asc' });
      // @ts-expect-error
      () => searchParams2({ group: 'hi', id: 'what' });
      // @ts-expect-error
      () => searchParams2.url({ group: 'hi', id: 'what' });
      expect(
        searchParams2.url({
          group: 'hi',
          id: 'what',
          bigger: true,
        }),
      ).toMatchInlineSnapshot(`"http://test.com/hi/user/what?bigger=true"`);

      const searchParams3 = getUserBase.extend({
        searchParams: {} as { bigger: boolean },
      });
      () => searchParams3({ id: 'what', bigger: true });
      () => searchParams3({ id: 'what', bigger: false });
      // @ts-expect-error
      () => searchParams3({ id: 'what', bigger: 5 });
      // @ts-expect-error
      () => searchParams3({ id: 'what', sort: 'asc' });
      // @ts-expect-error
      () => searchParams3({ id: 'what' });
      // @ts-expect-error
      () => searchParams3.url({ id: 'what' });
      expect(
        searchParams3.url({
          id: 'what',
          bigger: true,
        }),
      ).toMatchInlineSnapshot(`"http://test.com/user/what?bigger=true"`);

      const searchParams4 = getUserBase
        .extend({
          path: '/users',
        })
        .extend({ searchParams: {} as { bigger?: boolean } | undefined });
      () => searchParams4({ bigger: true });
      () => searchParams4();
      () => searchParams4({});
      // @ts-expect-error
      () => searchParams4({ id: 'what', bigger: false });
      // @ts-expect-error
      () => searchParams4({ bigger: 5 });
      // @ts-expect-error
      () => searchParams4({ id: 'what' });
      // @ts-expect-error
      () => searchParams4.url({ id: 'what' });
      expect(
        searchParams4.url({
          bigger: true,
        }),
      ).toMatchInlineSnapshot(`"/users?bigger=true"`);
      expect(searchParams4.url()).toMatchInlineSnapshot(`"/users"`);
    });

    it('should work with custom searchToString', async () => {
      class SearchEndpoint<O extends RestGenerics = any> extends MyEndpoint<O> {
        searchToString(searchParams: Record<string, any>) {
          return super.searchToString({ ...searchParams, bob: 5 });
        }
      }

      mynock.get('/6/user/5').reply(200, (uri, body: any) => ({
        id: 5,
        username2: 'charles',
        ...body,
      }));
      class User2 extends Entity {
        readonly id: number | undefined = undefined;
        readonly username2: string = '';
        readonly email: string = '';
        readonly isAdmin: boolean = false;

        pk() {
          return this.id?.toString();
        }
      }

      const getUserBase = new SearchEndpoint({
        method: 'GET',
        path: 'http\\://test.com/user/:id',
        name: 'getuser',
        schema: User,
      });
      const getUser = getUserBase.extend({
        path: 'http\\://test.com/:group/user/:id',
        schema: User2,
      });

      const searchParams = getUser.extend({
        path: 'http\\://test.com/:group/user/:id',
        searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' },
      });

      expect(
        searchParams.url({
          group: 'hi',
          id: 'what',
          sort: 'desc',
          isAdmin: true,
        }),
      ).toMatchInlineSnapshot(
        `"http://test.com/hi/user/what?bob=5&isAdmin=true&sort=desc"`,
      );
    });
  });

  it('extending with name should work', () => {
    const endpoint = CoolerArticleResource.get.extend({ name: 'mything' });
    const endpoint2 = CoolerArticleResource.get.extend({ path: '/:bob' });
    expect(CoolerArticleResource.get.name).toMatchInlineSnapshot(
      `"CoolerArticle.get"`,
    );
    expect(endpoint.name).toBe('mything');
    expect(endpoint2.name).toMatchInlineSnapshot(`"CoolerArticle.get"`);
  });
  it('should infer default method when sideEffect is set', async () => {
    const endpoint = new RestEndpoint({
      sideEffect: true,
      path: 'http\\://test.com/article-cooler',
      schema: CoolerArticle,
    }).extend({ name: 'createarticle' });
    const a: true = endpoint.sideEffect;
    const b: 'POST' = endpoint.method;
    expect(endpoint.method).toBe('POST');
    expect(endpoint.sideEffect).toBe(true);
    const article = await endpoint(payload);
    expect(article).toMatchInlineSnapshot(`
      {
        "content": "whatever",
        "id": 1,
        "tags": [
          "a",
          "best",
          "react",
        ],
        "title": "hi ho",
      }
    `);
  });

  describe('body type setting', () => {
    it('should work in constructors', () => {
      interface TodoInterface {
        title: string;
        completed: boolean;
      }
      const update = new RestEndpoint({
        path: '/:id',
        method: 'POST',
        body: {} as TodoInterface,
      });
      () => update({ id: 5 }, { title: 'updated', completed: true });
      // @ts-expect-error
      () => update({ id: 5 });
      // @ts-expect-error
      () => update({ id: 5 }, { title: 5, completed: true });
      // @ts-expect-error
      () => update({ id: 5 }, { completed: true });
    });
  });

  describe('process() return type setting', () => {
    const getArticle = new RestEndpoint({
      path: 'http\\://test.com/article-cooler/:id',
      process(value): CoolerArticle {
        return value;
      },
    });

    it('should work with constructors', async () => {
      const article = await getArticle({ id: '5' });
      article.author;
      // @ts-expect-error
      article.asdf;
      () => useSuspense(getArticle, { id: '5' }).content;
      // @ts-expect-error
      () => useSuspense(getArticle, { id: '5' }).asdf;
    });

    it('should set on .extend()', async () => {
      const getExtends = new RestEndpoint({
        path: 'http\\://test.com/article-cooler/:id',
      }).extend({
        process(value: any): CoolerArticle {
          return value;
        },
      });
      const ex = await getExtends({ id: '5' });
      ex.author;
      // @ts-expect-error
      ex.asdf;
      () => useSuspense(getExtends, { id: '5' }).content;
      // @ts-expect-error
      () => useSuspense(getExtends, { id: '5' }).asdf;
    });

    it('should override existing type on .extend()', async () => {
      const getOverride = getArticle.extend({
        process(value: any, param: any): { asdf: string } {
          return value;
        },
      });
      const ov = await getOverride({ id: '5' });
      ov.asdf;
      // @ts-expect-error
      ov.author;
      () => useSuspense(getOverride, { id: '5' }).asdf;
      // @ts-expect-error
      () => useSuspense(getOverride, { id: '5' }).content;
    });

    it('should maintain existing type on .extend() when not specified', async () => {
      const getOverride = getArticle.extend({
        dataExpiryLength: 7,
      });
      const ov = await getOverride({ id: '5' });
      ov.author;
      // @ts-expect-error
      ov.asdf;
    });

    it('should maintain existing type on .extend() when process is not supplied but path is', async () => {
      const getOverride = getArticle.extend({
        path: '/:a/:b',
      });
      async () => {
        const ov = await getOverride({ a: '5', b: '7' });
        ov.author;
        // @ts-expect-error
        ov.asdf;
      };
    });

    it('should override existing type on .extend() when path is also supplied', async () => {
      const getOverride = getArticle.extend({
        path: '/:a/:b',
        process(value: any, param: any): { asdf: string } {
          return value;
        },
      });
      async () => {
        const ov = await getOverride({ a: '5', b: '7' });
        ov.asdf;
        // @ts-expect-error
        ov.author;
      };
      () => useSuspense(getOverride, { a: '5', b: '7' }).asdf;
      // @ts-expect-error
      () => useSuspense(getOverride, { a: '5', b: '7' }).content;
    });
  });

  it('extend options should match function of path set', () => {
    const endpoint = new RestEndpoint({
      sideEffect: true,
      path: 'http\\://test.com/article-cooler/:id',
      body: 0 as any,
      schema: CoolerArticle,
      getOptimisticResponse(snap, params, body) {
        params.id;
        // @ts-expect-error
        params.two;

        body.hi;
      },
    }).extend({
      path: '/:group/next/:two',
      body: undefined,
      getOptimisticResponse(snap, params) {
        params.two;
        params.group;
        // @ts-expect-error
        params.id;
      },
    });

    const endpoint2 = new RestEndpoint({
      sideEffect: true,
      path: 'http\\://test.com/article-cooler/:id',
      body: 0 as any,
      schema: CoolerArticle,
      getOptimisticResponse(snap, params, body) {
        params.id;
        // @ts-expect-error
        params.two;

        body.hi;
      },
    }).extend({
      getOptimisticResponse(snap, params, body) {
        params.id;
        // @ts-expect-error
        params.two;

        body.hi;
      },
    });
  });
});

describe('RestEndpoint.fetch()', () => {
  const id = 5;
  const idHtml = 6;
  const idNoContent = 7;
  const payload = {
    id,
    title: 'happy',
    author: User.fromJS({ id: 5 }),
  };
  const putResponseBody = {
    id,
    title: 'happy',
    completed: true,
  };
  const patchPayload = {
    title: 'happy',
  };
  const patchResponseBody = {
    id,
    title: 'happy',
    completed: false,
  };

  beforeEach(() => {
    nock(/.*/)
      .defaultReplyHeaders({
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'application/json',
      })
      .options(/.*/)
      .reply(200)
      .get(`/article-cooler/${payload.id}`)
      .reply(200, payload)
      .get(`/article-cooler/${idHtml}`)
      .reply(200, '<body>this is html</body>')
      .get(`/article-cooler/${idNoContent}`)
      .reply(204, '')
      .post('/article-cooler')
      .reply((uri, requestBody) => [
        201,
        requestBody,
        { 'content-type': 'application/json' },
      ])
      .put('/article-cooler/5')
      .reply((uri, requestBody) => {
        let body = requestBody as any;
        if (typeof requestBody === 'string') {
          body = JSON.parse(requestBody);
        }
        for (const key of Object.keys(CoolerArticle.fromJS({}))) {
          if (key !== 'id' && !(key in body)) {
            return [400, {}, { 'content-type': 'application/json' }];
          }
        }
        return [200, putResponseBody, { 'content-type': 'application/json' }];
      })
      .patch('/article-cooler/5')
      .reply(() => [
        200,
        patchResponseBody,
        { 'content-type': 'application/json' },
      ])
      .intercept('/article-cooler/5', 'DELETE')
      .reply(200, {});
  });

  afterEach(() => {
    nock.cleanAll();
  });

  it('should GET', async () => {
    const article = await CoolerArticleResource.get({
      id: payload.id,
    });
    expect(article).toBeDefined();
    if (!article) {
      throw new Error('ahh');
    }
    expect(article.title).toBe(payload.title);
  });

  it('should POST', async () => {
    const payload2 = { id: 20, content: 'better task' };
    const article = await CoolerArticleResource.create(payload2);
    expect(article).toMatchObject(payload2);
  });

  it('should PUT with multipart form data', async () => {
    const payload2 = { id: 500, content: 'another' };
    let lastRequest: any;
    nock(/.*/)
      .defaultReplyHeaders({
        'Access-Control-Allow-Origin': '*',
      })
      .put('/article-cooler/500')
      .reply(function (uri, requestBody) {
        lastRequest = this.req;
        return [201, payload2, { 'Content-Type': 'application/json' }];
      });
    const newPhoto = new Blob();
    const body = new FormData();
    body.append('photo', newPhoto);

    const article = await CoolerArticleResource.update.extend({
      path: CoolerArticleResource.update.path,
      body: new FormData(),
    })({ id: '500' }, body);
    expect(lastRequest.headers['content-type']).toContain(
      'multipart/form-data',
    );
    expect(article).toMatchObject(payload2);
  });

  it('should DELETE', async () => {
    const res = await CoolerArticleResource.delete({
      id: payload.id,
    });
    expect(res).toEqual({ id });
  });

  it('should PUT', async () => {
    const response = await CoolerArticleResource.update(
      { id: payload.id },
      { ...CoolerArticle.fromJS(payload) },
    );
    expect(response).toEqual(putResponseBody);
  });

  it('should PATCH', async () => {
    const response = await CoolerArticleResource.partialUpdate(
      { id },
      patchPayload,
    );
    expect(response).toEqual(patchResponseBody);
  });

  it('should throw if response is not json', async () => {
    let error: any;
    try {
      await CoolerArticleResource.get({ id: idHtml });
    } catch (e) {
      error = e;
    }
    expect(error).toBeDefined();
    // This is very weird, but we're forced to use node-fetch for react native
    // node-fetch doesn't handle errors consistently with normal fetch implementations, so this won't work
    // react-native itself should match this correctly however.
    if (typeof window !== 'undefined') expect(error.status).toBe(400);
  });

  it('should throw if network is down', async () => {
    const oldError = console.error;
    console.error = () => {};

    const id = 10;
    nock(/.*/)
      .defaultReplyHeaders({
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'application/json',
      })
      .get(`/article-cooler/${id}`)
      .replyWithError(new TypeError('Network Down'));

    let error: any;
    try {
      await CoolerArticleResource.get({ id });
    } catch (e) {
      error = e;
    }
    expect(error).toBeDefined();
    expect(error.status).toBe(500);

    console.error = oldError;
  });

  it('should return raw response if status is 204 No Content', async () => {
    const res = await CoolerArticleResource.get({ id: idNoContent });
    expect(res).toBe(null);
  });

  it('should reject if content-type is not json with schema', async () => {
    const id = 8;
    const text = '<body>this is html</body>';
    nock(/.*/)
      .defaultReplyHeaders({
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'application/json',
      })
      .get(`/article-cooler/${id}`)
      .reply(200, text, { 'content-type': 'html/text' });

    await expect(
      async () => await CoolerArticleResource.get({ id }),
    ).rejects.toMatchSnapshot();
  });

  it('should reject if content-type does not exist with schema', async () => {
    const id = 10;
    const text = '<body>this is html</body>';
    nock(/.*/)
      .defaultReplyHeaders({
        'Access-Control-Allow-Origin': '*',
      })
      .get(`/article-cooler/${id}`)
      .reply(200, text, {});

    await expect(
      async () => await CoolerArticleResource.get({ id }),
    ).rejects.toMatchSnapshot();
  });

  it('should return text if content-type is not json with no schema', async () => {
    const id = 8;
    const text = '<body>this is html</body>';
    nock(/.*/)
      .defaultReplyHeaders({
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'application/json',
      })
      .get(`/article-cooler/${id}`)
      .reply(200, text, { 'content-type': 'html/text' });

    const res = await CoolerArticleResource.get.extend({ schema: undefined })({
      id,
    });
    expect(res).toBe('<body>this is html</body>');
  });

  it('should return text if content-type does not exist with no schema', async () => {
    const id = 10;
    const text = '<body>this is html</body>';
    nock(/.*/)
      .defaultReplyHeaders({
        'Access-Control-Allow-Origin': '*',
      })
      .get(`/article-cooler/${id}`)
      .reply(200, text, {});

    const res = await CoolerArticleResource.get.extend({ schema: undefined })({
      id,
    });
    expect(res).toBe(text);
  });

  it('should reject with custom message if content type is set but json parsable', async () => {
    const id = 8;
    nock(/.*/)
      .defaultReplyHeaders({
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'text',
      })
      .get(`/article-cooler/${id}`)
      .reply(200, { id, title: 'hi' }, {});

    await expect(
      async () => await CoolerArticleResource.get({ id }),
    ).rejects.toMatchSnapshot();
  });

  it('should still work with empty content-type', async () => {
    const id = 8;
    nock(/.*/)
      .defaultReplyHeaders({
        'Access-Control-Allow-Origin': '*',
      })
      .get(`/article-cooler/${id}`)
      .reply(200, { id, title: 'hi' });

    const res = await CoolerArticleResource.get({
      id,
    });
    expect(res).toEqual({ id, title: 'hi' });
  });

  it('without Collection in schema - endpoint.push schema should be null', () => {
    const noColletionEndpoint = new RestEndpoint({
      urlPrefix: 'http://test.com/article-paginated/',
      path: ':group',
      name: 'get',
      schema: {
        nextPage: '',
        data: { results: [PaginatedArticle] },
      },
      method: 'GET',
    });
    expect(noColletionEndpoint.push.schema).toBeFalsy();
  });

  it('without Collection in schema - endpoint.getPage should throw', () => {
    const noColletionEndpoint = new RestEndpoint({
      urlPrefix: 'http://test.com/article-paginated/',
      path: ':group',
      name: 'get',
      schema: {
        nextPage: '',
        data: { results: [PaginatedArticle] },
      },
      method: 'GET',
    });
    expect(() => noColletionEndpoint.getPage).toThrowErrorMatchingSnapshot();
  });
});
const proto = Object.prototype;
const gpo = Object.getPrototypeOf;

function isPojo(obj: unknown): obj is Record<string, any> {
  if (obj === null || typeof obj !== 'object') {
    return false;
  }
  return gpo(obj) === proto;
}
