import Observable from 'zen-observable-ts';
import gql from 'graphql-tag';
import { print } from 'graphql/language/printer';

import { execute, ApolloLink, from, split, concat } from '../link';
import { MockLink, SetContextLink, testLinkResults } from '../test-utils';
import { FetchResult, Operation, NextLink } from '../types';

const sampleQuery = gql`
  query SampleQuery {
    stub {
      id
    }
  }
`;

const setContext = () => ({ add: 1 });
describe('ApolloLink(abstract class)', () => {
  describe('concat', () => {
    it('should concat a function', done => {
      const returnOne = new SetContextLink(setContext);
      const link = returnOne.concat((operation, forward) => {
        return Observable.of({ data: { count: operation.getContext().add } });
      });

      testLinkResults({
        link,
        results: [{ count: 1 }],
        done,
      });
    });

    it('should concat a Link', done => {
      const returnOne = new SetContextLink(setContext);
      const mock = new MockLink(op =>
        Observable.of({ data: op.getContext().add }),
      );
      const link = returnOne.concat(mock);

      testLinkResults({
        link,
        results: [1],
        done,
      });
    });

    it("should pass error to observable's error", done => {
      const error = new Error('thrown');
      const returnOne = new SetContextLink(setContext);
      const mock = new MockLink(
        op =>
          new Observable(observer => {
            observer.next({ data: op.getContext().add });
            observer.error(error);
          }),
      );
      const link = returnOne.concat(mock);

      testLinkResults({
        link,
        results: [1, error],
        done,
      });
    });

    it('should concat a Link and function', done => {
      const returnOne = new SetContextLink(setContext);
      const mock = new MockLink((op, forward) => {
        op.setContext(({ add }) => ({ add: add + 2 }));
        return forward(op);
      });
      const link = returnOne.concat(mock).concat(op => {
        return Observable.of({ data: op.getContext().add });
      });

      testLinkResults({
        link,
        results: [3],
        done,
      });
    });

    it('should concat a function and Link', done => {
      const returnOne = new SetContextLink(setContext);
      const mock = new MockLink((op, forward) =>
        Observable.of({ data: op.getContext().add }),
      );

      const link = returnOne
        .concat((operation, forward) => {
          operation.setContext({
            add: operation.getContext().add + 2,
          });
          return forward(operation);
        })
        .concat(mock);
      testLinkResults({
        link,
        results: [3],
        done,
      });
    });

    it('should concat two functions', done => {
      const returnOne = new SetContextLink(setContext);
      const link = returnOne
        .concat((operation, forward) => {
          operation.setContext({
            add: operation.getContext().add + 2,
          });
          return forward(operation);
        })
        .concat((op, forward) => Observable.of({ data: op.getContext().add }));
      testLinkResults({
        link,
        results: [3],
        done,
      });
    });

    it('should concat two Links', done => {
      const returnOne = new SetContextLink(setContext);
      const mock1 = new MockLink((operation, forward) => {
        operation.setContext({
          add: operation.getContext().add + 2,
        });
        return forward(operation);
      });
      const mock2 = new MockLink((op, forward) =>
        Observable.of({ data: op.getContext().add }),
      );

      const link = returnOne.concat(mock1).concat(mock2);
      testLinkResults({
        link,
        results: [3],
        done,
      });
    });

    it("should return an link that can be concat'd multiple times", done => {
      const returnOne = new SetContextLink(setContext);
      const mock1 = new MockLink((operation, forward) => {
        operation.setContext({
          add: operation.getContext().add + 2,
        });
        return forward(operation);
      });
      const mock2 = new MockLink((op, forward) =>
        Observable.of({ data: op.getContext().add + 2 }),
      );
      const mock3 = new MockLink((op, forward) =>
        Observable.of({ data: op.getContext().add + 3 }),
      );
      const link = returnOne.concat(mock1);

      testLinkResults({
        link: link.concat(mock2),
        results: [5],
      });
      testLinkResults({
        link: link.concat(mock3),
        results: [6],
        done,
      });
    });
  });

  describe('split', () => {
    it('should split two functions', done => {
      const context = { add: 1 };
      const returnOne = new SetContextLink(() => context);
      const link1 = returnOne.concat((operation, forward) =>
        Observable.of({ data: operation.getContext().add + 1 }),
      );
      const link2 = returnOne.concat((operation, forward) =>
        Observable.of({ data: operation.getContext().add + 2 }),
      );
      const link = returnOne.split(
        operation => operation.getContext().add === 1,
        link1,
        link2,
      );

      testLinkResults({
        link,
        results: [2],
      });

      context.add = 2;

      testLinkResults({
        link,
        results: [4],
        done,
      });
    });

    it('should split two Links', done => {
      const context = { add: 1 };
      const returnOne = new SetContextLink(() => context);
      const link1 = returnOne.concat(
        new MockLink((operation, forward) =>
          Observable.of({ data: operation.getContext().add + 1 }),
        ),
      );
      const link2 = returnOne.concat(
        new MockLink((operation, forward) =>
          Observable.of({ data: operation.getContext().add + 2 }),
        ),
      );
      const link = returnOne.split(
        operation => operation.getContext().add === 1,
        link1,
        link2,
      );

      testLinkResults({
        link,
        results: [2],
      });

      context.add = 2;

      testLinkResults({
        link,
        results: [4],
        done,
      });
    });

    it('should split a link and a function', done => {
      const context = { add: 1 };
      const returnOne = new SetContextLink(() => context);
      const link1 = returnOne.concat((operation, forward) =>
        Observable.of({ data: operation.getContext().add + 1 }),
      );
      const link2 = returnOne.concat(
        new MockLink((operation, forward) =>
          Observable.of({ data: operation.getContext().add + 2 }),
        ),
      );
      const link = returnOne.split(
        operation => operation.getContext().add === 1,
        link1,
        link2,
      );

      testLinkResults({
        link,
        results: [2],
      });

      context.add = 2;

      testLinkResults({
        link,
        results: [4],
        done,
      });
    });

    it('should allow concat after split to be join', done => {
      const context = { test: true, add: 1 };
      const start = new SetContextLink(() => ({ ...context }));
      const link = start
        .split(
          operation => operation.getContext().test,
          (operation, forward) => {
            operation.setContext(({ add }) => ({ add: add + 1 }));
            return forward(operation);
          },
          (operation, forward) => {
            operation.setContext(({ add }) => ({ add: add + 2 }));
            return forward(operation);
          },
        )
        .concat(operation =>
          Observable.of({ data: operation.getContext().add }),
        );

      testLinkResults({
        link,
        context,
        results: [2],
      });

      context.test = false;

      testLinkResults({
        link,
        context,
        results: [3],
        done,
      });
    });

    it('should allow default right to be empty or passthrough when forward available', done => {
      let context = { test: true };
      const start = new SetContextLink(() => context);
      const link = start.split(
        operation => operation.getContext().test,
        operation =>
          Observable.of({
            data: {
              count: 1,
            },
          }),
      );
      const concat = link.concat(operation =>
        Observable.of({
          data: {
            count: 2,
          },
        }),
      );

      testLinkResults({
        link,
        results: [{ count: 1 }],
      });

      context.test = false;

      testLinkResults({
        link,
        results: [],
      });

      testLinkResults({
        link: concat,
        results: [{ count: 2 }],
        done,
      });
    });
  });

  describe('empty', () => {
    it('should returns an immediately completed Observable', done => {
      testLinkResults({
        link: ApolloLink.empty(),
        done,
      });
    });
  });
});
describe('context', () => {
  it('should merge context when using a function', done => {
    const returnOne = new SetContextLink(setContext);
    const mock = new MockLink((op, forward) => {
      op.setContext(({ add }) => ({ add: add + 2 }));
      op.setContext(() => ({ substract: 1 }));

      return forward(op);
    });
    const link = returnOne.concat(mock).concat(op => {
      expect(op.getContext()).toEqual({
        add: 3,
        substract: 1,
      });
      return Observable.of({ data: op.getContext().add });
    });

    testLinkResults({
      link,
      results: [3],
      done,
    });
  });
  it('should merge context when not using a function', done => {
    const returnOne = new SetContextLink(setContext);
    const mock = new MockLink((op, forward) => {
      op.setContext({ add: 3 });
      op.setContext({ substract: 1 });

      return forward(op);
    });
    const link = returnOne.concat(mock).concat(op => {
      expect(op.getContext()).toEqual({
        add: 3,
        substract: 1,
      });
      return Observable.of({ data: op.getContext().add });
    });

    testLinkResults({
      link,
      results: [3],
      done,
    });
  });
});

describe('Link static library', () => {
  describe('from', () => {
    const uniqueOperation: Operation = {
      query: sampleQuery,
      context: { name: 'uniqueName' },
      operationName: 'SampleQuery',
      extensions: {},
    };

    it('should create an observable that completes when passed an empty array', done => {
      const observable = execute(from([]), {
        query: sampleQuery,
      });
      observable.subscribe(() => expect(false), () => expect(false), done);
    });

    it('can create chain of one', () => {
      expect(() => ApolloLink.from([new MockLink()])).not.toThrow();
    });

    it('can create chain of two', () => {
      expect(() =>
        ApolloLink.from([
          new MockLink((operation, forward) => forward(operation)),
          new MockLink(),
        ]),
      ).not.toThrow();
    });

    it('should receive result of one link', done => {
      const data: FetchResult = {
        data: {
          hello: 'world',
        },
      };
      const chain = ApolloLink.from([new MockLink(() => Observable.of(data))]);
      // Smoke tests execute as a static method
      const observable = ApolloLink.execute(chain, uniqueOperation);
      observable.subscribe({
        next: actualData => {
          expect(data).toEqual(actualData);
        },
        error: () => {
          throw new Error();
        },
        complete: () => done(),
      });
    });

    it('should accept AST query and pass AST to link', () => {
      const astOperation = {
        ...uniqueOperation,
        query: sampleQuery,
      };

      const stub = jest.fn();

      const chain = ApolloLink.from([new MockLink(stub)]);
      execute(chain, astOperation);

      expect(stub).toBeCalledWith({
        query: sampleQuery,
        operationName: 'SampleQuery',
        variables: {},
        extensions: {},
      });
    });

    it('should pass operation from one link to next with modifications', done => {
      const chain = ApolloLink.from([
        new MockLink((op, forward) =>
          forward({
            ...op,
            query: sampleQuery,
          }),
        ),
        new MockLink(op => {
          expect({
            extensions: {},
            operationName: 'SampleQuery',
            query: sampleQuery,
            variables: {},
          }).toEqual(op);
          return done();
        }),
      ]);
      execute(chain, uniqueOperation);
    });

    it('should pass result of one link to another with forward', done => {
      const data: FetchResult = {
        data: {
          hello: 'world',
        },
      };

      const chain = ApolloLink.from([
        new MockLink((op, forward) => {
          const observable = forward(op);

          observable.subscribe({
            next: actualData => {
              expect(data).toEqual(actualData);
            },
            error: () => {
              throw new Error();
            },
            complete: done,
          });

          return observable;
        }),
        new MockLink(() => Observable.of(data)),
      ]);
      execute(chain, uniqueOperation);
    });

    it('should receive final result of two link chain', done => {
      const data: FetchResult = {
        data: {
          hello: 'world',
        },
      };

      const chain = ApolloLink.from([
        new MockLink((op, forward) => {
          const observable = forward(op);

          return new Observable(observer => {
            observable.subscribe({
              next: actualData => {
                expect(data).toEqual(actualData);
                observer.next({
                  data: {
                    ...actualData.data,
                    modification: 'unique',
                  },
                });
              },
              error: error => observer.error(error),
              complete: () => observer.complete(),
            });
          });
        }),
        new MockLink(() => Observable.of(data)),
      ]);

      const result = execute(chain, uniqueOperation);

      result.subscribe({
        next: modifiedData => {
          expect({
            data: {
              ...data.data,
              modification: 'unique',
            },
          }).toEqual(modifiedData);
        },
        error: () => {
          throw new Error();
        },
        complete: done,
      });
    });

    it('should chain together a function with links', done => {
      const add1 = new ApolloLink((operation: Operation, forward: NextLink) => {
        operation.setContext(({ num }) => ({ num: num + 1 }));
        return forward(operation);
      });
      const add1Link = new MockLink((operation, forward) => {
        operation.setContext(({ num }) => ({ num: num + 1 }));
        return forward(operation);
      });

      const link = ApolloLink.from([
        add1,
        add1,
        add1Link,
        add1,
        add1Link,
        new ApolloLink(operation =>
          Observable.of({ data: operation.getContext() }),
        ),
      ]);
      testLinkResults({
        link,
        results: [{ num: 5 }],
        context: { num: 0 },
        done,
      });
    });
  });

  describe('split', () => {
    it('should create filter when single link passed in', done => {
      const link = split(
        operation => operation.getContext().test,
        (operation, forward) => Observable.of({ data: { count: 1 } }),
      );

      let context = { test: true };

      testLinkResults({
        link,
        results: [{ count: 1 }],
        context,
      });

      context.test = false;

      testLinkResults({
        link,
        results: [],
        context,
        done,
      });
    });

    it('should split two functions', done => {
      const link = ApolloLink.split(
        operation => operation.getContext().test,
        (operation, forward) => Observable.of({ data: { count: 1 } }),
        (operation, forward) => Observable.of({ data: { count: 2 } }),
      );

      let context = { test: true };

      testLinkResults({
        link,
        results: [{ count: 1 }],
        context,
      });

      context.test = false;

      testLinkResults({
        link,
        results: [{ count: 2 }],
        context,
        done,
      });
    });

    it('should split two Links', done => {
      const link = ApolloLink.split(
        operation => operation.getContext().test,
        (operation, forward) => Observable.of({ data: { count: 1 } }),
        new MockLink((operation, forward) =>
          Observable.of({ data: { count: 2 } }),
        ),
      );

      let context = { test: true };

      testLinkResults({
        link,
        results: [{ count: 1 }],
        context,
      });

      context.test = false;

      testLinkResults({
        link,
        results: [{ count: 2 }],
        context,
        done,
      });
    });

    it('should split a link and a function', done => {
      const link = ApolloLink.split(
        operation => operation.getContext().test,
        (operation, forward) => Observable.of({ data: { count: 1 } }),
        new MockLink((operation, forward) =>
          Observable.of({ data: { count: 2 } }),
        ),
      );

      let context = { test: true };

      testLinkResults({
        link,
        results: [{ count: 1 }],
        context,
      });

      context.test = false;

      testLinkResults({
        link,
        results: [{ count: 2 }],
        context,
        done,
      });
    });

    it('should allow concat after split to be join', done => {
      const context = { test: true };
      const link = ApolloLink.split(
        operation => operation.getContext().test,
        (operation, forward) =>
          forward(operation).map(data => ({
            data: { count: data.data.count + 1 },
          })),
      ).concat(() => Observable.of({ data: { count: 1 } }));

      testLinkResults({
        link,
        context,
        results: [{ count: 2 }],
      });

      context.test = false;

      testLinkResults({
        link,
        context,
        results: [{ count: 1 }],
        done,
      });
    });

    it('should allow default right to be passthrough', done => {
      const context = { test: true };
      const link = ApolloLink.split(
        operation => operation.getContext().test,
        operation => Observable.of({ data: { count: 2 } }),
      ).concat(operation => Observable.of({ data: { count: 1 } }));

      testLinkResults({
        link,
        context,
        results: [{ count: 2 }],
      });

      context.test = false;

      testLinkResults({
        link,
        context,
        results: [{ count: 1 }],
        done,
      });
    });
  });

  describe('execute', () => {
    let _warn: (message?: any, ...originalParams: any[]) => void;

    beforeEach(() => {
      _warn = console.warn;
      console.warn = jest.fn(warning => {
        expect(warning).toBe(`query should either be a string or GraphQL AST`);
      });
    });

    afterEach(() => {
      console.warn = _warn;
    });

    it('should return an empty observable when a link returns null', done => {
      testLinkResults({
        link: new MockLink(),
        results: [],
        done,
      });
    });

    it('should return an empty observable when a link is empty', done => {
      testLinkResults({
        link: ApolloLink.empty(),
        results: [],
        done,
      });
    });

    it("should return an empty observable when a concat'd link returns null", done => {
      const link = new MockLink((operation, forward) => {
        return forward(operation);
      }).concat(() => null);
      testLinkResults({
        link,
        results: [],
        done,
      });
    });

    it('should return an empty observable when a split link returns null', done => {
      let context = { test: true };
      const link = new SetContextLink(() => context).split(
        op => op.getContext().test,
        () => Observable.of(),
        () => null,
      );
      testLinkResults({
        link,
        results: [],
      });
      context.test = false;
      testLinkResults({
        link,
        results: [],
        done,
      });
    });

    it('should set a default context, variable, query and operationName on a copy of operation', done => {
      const operation = {
        query: gql`
          {
            id
          }
        `,
      };
      const link = new ApolloLink(op => {
        expect(operation['operationName']).toBeUndefined();
        expect(operation['variables']).toBeUndefined();
        expect(operation['context']).toBeUndefined();
        expect(operation['extensions']).toBeUndefined();
        expect(op['operationName']).toBeDefined();
        expect(op['variables']).toBeDefined();
        expect(op['context']).toBeUndefined();
        expect(op['extensions']).toBeDefined();
        expect(op.toKey()).toBeDefined();
        return Observable.of();
      });

      execute(link, operation).subscribe({
        complete: done,
      });
    });
  });
});

describe('Terminating links', () => {
  const _warn = console.warn;
  const warningStub = jest.fn(warning => {
    expect(warning.message).toBe(
      `You are calling concat on a terminating link, which will have no effect`,
    );
  });
  const data = {
    stub: 'data',
  };

  beforeAll(() => {
    console.warn = warningStub;
  });

  beforeEach(() => {
    warningStub.mockClear();
  });

  afterAll(() => {
    console.warn = _warn;
  });

  describe('concat', () => {
    it('should warn if attempting to concat to a terminating Link from function', () => {
      const link = new ApolloLink(operation => Observable.of({ data }));
      expect(concat(link, (operation, forward) => forward(operation))).toEqual(
        link,
      );
      expect(warningStub).toHaveBeenCalledTimes(1);
      expect(warningStub.mock.calls[0][0].link).toEqual(link);
    });

    it('should warn if attempting to concat to a terminating Link', () => {
      const link = new MockLink(operation => Observable.of());
      expect(link.concat((operation, forward) => forward(operation))).toEqual(
        link,
      );
      expect(warningStub).toHaveBeenCalledTimes(1);
      expect(warningStub.mock.calls[0][0].link).toEqual(link);
    });

    it('should not warn if attempting concat a terminating Link at end', () => {
      const link = new MockLink((operation, forward) => forward(operation));
      link.concat(operation => Observable.of());
      expect(warningStub).not.toBeCalled();
    });
  });

  describe('split', () => {
    it('should not warn if attempting to split a terminating and non-terminating Link', () => {
      const split = ApolloLink.split(
        () => true,
        operation => Observable.of({ data }),
        (operation, forward) => forward(operation),
      );
      split.concat((operation, forward) => forward(operation));
      expect(warningStub).not.toBeCalled();
    });

    it('should warn if attempting to concat to split two terminating links', () => {
      const split = ApolloLink.split(
        () => true,
        operation => Observable.of({ data }),
        operation => Observable.of({ data }),
      );
      expect(split.concat((operation, forward) => forward(operation))).toEqual(
        split,
      );
      expect(warningStub).toHaveBeenCalledTimes(1);
    });

    it('should warn if attempting to split to split two terminating links', () => {
      const split = ApolloLink.split(
        () => true,
        operation => Observable.of({ data }),
        operation => Observable.of({ data }),
      );
      expect(
        split.split(
          () => true,
          (operation, forward) => forward(operation),
          (operation, forward) => forward(operation),
        ),
      ).toEqual(split);
      expect(warningStub).toHaveBeenCalledTimes(1);
    });
  });

  describe('from', () => {
    it('should not warn if attempting to form a terminating then non-terminating Link', () => {
      ApolloLink.from([
        (operation, forward) => forward(operation),
        operation => Observable.of({ data }),
      ]);
      expect(warningStub).not.toBeCalled();
    });

    it('should warn if attempting to add link after termination', () => {
      ApolloLink.from([
        (operation, forward) => forward(operation),
        operation => Observable.of({ data }),
        (operation, forward) => forward(operation),
      ]);
      expect(warningStub).toHaveBeenCalledTimes(1);
    });

    it('should warn if attempting to add link after termination', () => {
      ApolloLink.from([
        new ApolloLink((operation, forward) => forward(operation)),
        new ApolloLink(operation => Observable.of({ data })),
        new ApolloLink((operation, forward) => forward(operation)),
      ]);
      expect(warningStub).toHaveBeenCalledTimes(1);
    });
  });

  describe('warning', () => {
    it('should include link that terminates', () => {
      const terminatingLink = new MockLink(operation =>
        Observable.of({ data }),
      );
      ApolloLink.from([
        new ApolloLink((operation, forward) => forward(operation)),
        new ApolloLink((operation, forward) => forward(operation)),
        terminatingLink,
        new ApolloLink((operation, forward) => forward(operation)),
        new ApolloLink((operation, forward) => forward(operation)),
        new ApolloLink(operation => Observable.of({ data })),
        new ApolloLink((operation, forward) => forward(operation)),
      ]);
      expect(warningStub).toHaveBeenCalledTimes(4);
    });
  });
});

describe('execute', () => {
  it('transforms an opearation with context into something serlizable', done => {
    const query = gql`
      {
        id
      }
    `;
    const link = new ApolloLink(operation => {
      const str = JSON.stringify({
        ...operation,
        query: print(operation.query),
      });

      expect(str).toBe(
        JSON.stringify({
          variables: { id: 1 },
          extensions: { cache: true },
          operationName: null,
          query: print(operation.query),
        }),
      );
      return Observable.of();
    });
    const noop = () => {};
    execute(link, {
      query,
      variables: { id: 1 },
      extensions: { cache: true },
    }).subscribe(noop, noop, done);
  });
});