import { of, throwError } from "rxjs";
import { retryOnErrorsCommandWrapper, sharedLogicTaskWrapper } from "./core";
import { DisconnectedDevice, LockedDeviceError } from "@ledgerhq/errors";
import { concatMap } from "rxjs/operators";
import { TransportRef } from "../transports/core";
import { aTransportRefBuilder } from "../mocks/aTransportRef";

// Needs to mock the timer from rxjs used in the retry mechanism
jest.mock("rxjs", () => {
  const originalModule = jest.requireActual("rxjs");

  return {
    ...originalModule,
    timer: jest.fn(() => {
      return of(1);
    }),
  };
});

describe("sharedLogicTaskWrapper", () => {
  const task = jest.fn();
  const wrappedTask = sharedLogicTaskWrapper<void, { type: "data" }>(task);

  afterAll(() => {
    task.mockClear();
  });

  describe("When the task emits an non-error event", () => {
    it("should pass the event through", done => {
      task.mockReturnValue(of({ type: "data" }));

      wrappedTask().subscribe({
        next: event => {
          try {
            expect(event).toEqual({ type: "data" });
            done();
          } catch (expectError) {
            done(expectError);
          }
        },
      });
    });
  });

  describe("When the task emits an error that is not handled by the shared logic", () => {
    it("should not retry the task and emits the error", done => {
      task.mockReturnValue(throwError(new Error("Unhandled error")));

      wrappedTask().subscribe({
        next: event => {
          try {
            expect(event).toEqual({
              type: "error",
              error: new Error("Unhandled error"),
              retrying: false,
            });
            done();
          } catch (expectError) {
            done(expectError);
          }
        },
      });
    });
  });

  describe("When the task emits an error that is handled by the shared logic", () => {
    it("should retry infinitely and emits an error event until a correct event is emitted", done => {
      let counter = 0;

      task.mockReturnValue(
        of({ type: "data" }).pipe(
          concatMap(event => {
            if (counter < 3) {
              return throwError(() => new LockedDeviceError("Handled error"));
            }

            return of(event);
          }),
        ),
      );

      wrappedTask().subscribe({
        next: event => {
          try {
            if (counter < 3) {
              expect(event).toEqual({
                type: "error",
                error: new LockedDeviceError("Handled error"),
                retrying: true,
              });
            } else {
              expect(event).toEqual({ type: "data" });
              done();
            }

            counter++;
          } catch (expectError) {
            done(expectError);
          }
        },
      });
    });
  });
});

describe("retryOnErrorsCommandWrapper", () => {
  const command = jest.fn();
  const disconnectedDeviceMaxRetries = 3;
  let transportRef: TransportRef;
  let wrappedCommand;

  beforeEach(async () => {
    transportRef = await aTransportRefBuilder();

    wrappedCommand = retryOnErrorsCommandWrapper<void, { type: "data" }>({
      command,
      allowedErrors: [
        {
          maxRetries: disconnectedDeviceMaxRetries,
          errorClass: DisconnectedDevice,
        },
        {
          maxRetries: "infinite",
          errorClass: LockedDeviceError,
        },
      ],
    });
  });

  afterAll(() => {
    command.mockClear();
  });

  describe("When the command emits an non-error event", () => {
    it("should pass the event through", done => {
      command.mockReturnValue(of({ type: "data" }));

      wrappedCommand(transportRef).subscribe({
        next: event => {
          try {
            expect(event).toEqual({ type: "data" });
            done();
          } catch (expectError) {
            done(expectError);
          }
        },
      });
    });
  });

  describe("When the command emits an error that is not set to be handled by the wrapper", () => {
    it("should not retry the command and throw the error", done => {
      command.mockReturnValue(throwError(() => new Error("Unhandled error")));

      wrappedCommand(transportRef).subscribe({
        error: error => {
          try {
            expect(error).toEqual(new Error("Unhandled error"));
            done();
          } catch (expectError) {
            done(expectError);
          }
        },
      });
    });
  });

  describe("When the command throws an error that is set to be handled by the wrapper, and this error can be retried a limited number of times", () => {
    it("should retry the defined limited number of time and not emit an error event until a correct event is emitted", done => {
      let counter = 0;

      command.mockReturnValue(
        of({ type: "data" }).pipe(
          concatMap(event => {
            // Increments before the condition check below so it could keep incrementing after reaching disconnectedDeviceMaxRetries
            // to make sure the event is received the first time it is emitted and no other retry occurred after
            counter++;

            // Throws an error until before the limit is reached
            if (counter < disconnectedDeviceMaxRetries) {
              return throwError(
                () => new DisconnectedDevice(`Handled error max ${disconnectedDeviceMaxRetries}`),
              );
            }

            return of(event);
          }),
        ),
      );

      wrappedCommand(transportRef).subscribe({
        next: event => {
          try {
            // It reaches disconnectedDeviceMaxRetries because of our condition inside the mocked task
            // but it could be anything <= disconnectedDeviceMaxRetries.
            expect(counter).toBe(disconnectedDeviceMaxRetries);
            // It should not receive error event, the retry is silent, and only the data event should be received
            expect(event).toEqual({ type: "data" });
            done();
          } catch (expectError) {
            done(expectError);
          }
        },
      });
    });

    it("should retry a limited number of time and throw the error if it is not resolved", done => {
      let counter = 0;

      command.mockReturnValue(
        of({ type: "data" }).pipe(
          concatMap(_event => {
            counter++;

            // Always throws an error, exceeding the set max retry
            return throwError(
              () => new DisconnectedDevice(`Handled error max ${disconnectedDeviceMaxRetries}`),
            );
          }),
        ),
      );

      wrappedCommand(transportRef).subscribe({
        error: error => {
          try {
            expect(counter).toBe(disconnectedDeviceMaxRetries + 1);

            expect(error).toEqual(
              new DisconnectedDevice(`Handled error max ${disconnectedDeviceMaxRetries}`),
            );
            done();
          } catch (expectError) {
            done(expectError);
          }
        },
      });
    });

    describe("and several type of errors are thrown", () => {
      it("should retry until one type of error is retried the maximum number of time in a row", done => {
        let counter = 0;

        command.mockReturnValue(
          of({ type: "data" }).pipe(
            concatMap(_event => {
              counter++;

              // Throws an error until just before the limit is reached
              if (counter < disconnectedDeviceMaxRetries) {
                return throwError(
                  () => new DisconnectedDevice(`Handled error max ${disconnectedDeviceMaxRetries}`),
                );
              }
              // Then throws a different handled error
              else if (counter < disconnectedDeviceMaxRetries + 1) {
                return throwError(() => new LockedDeviceError("Handled error"));
              }
              // Finally throws again the first limited handled error
              // It should retry again until disconnctedDeviceMaxRetries is again reached
              // Which is counter == disconnectedDeviceMaxRetries * 2 + 1
              else {
                return throwError(
                  () => new DisconnectedDevice(`Handled error max ${disconnectedDeviceMaxRetries}`),
                );
              }
            }),
          ),
        );

        const expectedCounterAtDisconnectedDeviceError = disconnectedDeviceMaxRetries * 2 + 1;

        wrappedCommand(transportRef).subscribe({
          error: error => {
            try {
              expect(counter).toBe(expectedCounterAtDisconnectedDeviceError);

              expect(error).toEqual(
                new DisconnectedDevice(`Handled error max ${disconnectedDeviceMaxRetries}`),
              );
              done();
            } catch (expectError) {
              done(expectError);
            }
          },
        });
      });
    });
  });

  describe("When the command throws an error that is set to be handled by the wrapper, and this error can be retried an infinite number of times", () => {
    it("should retry infinitely, without throwing an error, until a correct event is emitted", done => {
      let counter = 0;

      // The default retry time is 500ms: testing a total time higher than the 5000ms that triggers a Jest timeout
      // as the time should be mocked/faked
      const randomNumberOfRetries = Math.floor(Math.random() * 5) + 11;

      command.mockReturnValue(
        of({ type: "data" }).pipe(
          concatMap(event => {
            counter++;

            // Throws an error until a random number of times
            if (counter < randomNumberOfRetries) {
              return throwError(
                () =>
                  new LockedDeviceError(
                    `Handled infinite retries error that should be thrown ${randomNumberOfRetries} times`,
                  ),
              );
            }

            return of(event);
          }),
        ),
      );

      wrappedCommand(transportRef).subscribe({
        next: event => {
          try {
            // No error or event should have been emitted before the correct event
            expect(counter).toBe(randomNumberOfRetries);
            expect(event).toEqual({ type: "data" });
            done();
          } catch (expectError) {
            done(expectError);
          }
        },
        error: error => done(error),
      });
    });
  });
});
