/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 */

import {FlipperDevicePlugin} from '../../plugin';
import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
import {Store, Client, sleep} from '../../';
import {
  selectPlugin,
  starPlugin,
  selectClient,
  selectDevice,
} from '../../reducers/connections';
import {processMessageQueue} from '../messageQueue';
import {getPluginKey} from '../pluginUtils';
import {TestIdler} from '../Idler';
import {registerPlugins} from '../../reducers/plugins';
import {
  SandyPluginDefinition,
  TestUtils,
  FlipperClient,
  SandyPluginInstance,
} from 'flipper-plugin';

type Events = {
  inc: {
    delta?: number;
  };
};

function plugin(client: FlipperClient<Events, {}>) {
  const state = {
    count: 0,
  };

  client.onMessage('inc', (params) => {
    state.count += params.delta || 1;
  });

  return {
    state,
  };
}

const TestPlugin = new SandyPluginDefinition(
  TestUtils.createMockPluginDetails(),
  {
    plugin,
    Component() {
      return null;
    },
  },
);

function starTestPlugin(store: Store, client: Client) {
  store.dispatch(
    starPlugin({
      plugin: TestPlugin,
      selectedApp: client.query.app,
    }),
  );
}

function selectDeviceLogs(store: Store) {
  store.dispatch(
    selectPlugin({
      selectedPlugin: 'DeviceLogs',
      selectedApp: null,
      deepLinkPayload: null,
      selectedDevice: store.getState().connections.selectedDevice!,
    }),
  );
}

function selectTestPlugin(store: Store, client: Client) {
  store.dispatch(
    selectPlugin({
      selectedPlugin: TestPlugin.id,
      selectedApp: client.query.app,
      deepLinkPayload: null,
      selectedDevice: store.getState().connections.selectedDevice!,
    }),
  );
}

function getTestPluginState(
  client: Client,
): ReturnType<typeof plugin>['state'] {
  return client.sandyPluginStates.get(TestPlugin.id)!.instanceApi.state;
}

test('queue - events are processed immediately if plugin is selected', async () => {
  const {store, client, sendMessage} = await createMockFlipperWithPlugin(
    TestPlugin,
  );
  expect(store.getState().connections.selectedPlugin).toBe('TestPlugin');
  sendMessage('noop', {});
  sendMessage('noop', {});
  sendMessage('inc', {});
  sendMessage('inc', {delta: 4});
  sendMessage('noop', {});
  client.flushMessageBuffer();
  expect(getTestPluginState(client)).toMatchInlineSnapshot(`
          Object {
            "count": 5,
          }
        `);
  expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(
    `Object {}`,
  );
});

test('queue - events are NOT processed immediately if plugin is NOT selected (but enabled)', async () => {
  const {
    store,
    client,
    sendMessage,
    device,
  } = await createMockFlipperWithPlugin(TestPlugin);
  selectDeviceLogs(store);
  expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin');

  sendMessage('inc', {});
  sendMessage('inc', {delta: 2});
  sendMessage('inc', {delta: 3});
  expect(store.getState().pluginStates).toMatchInlineSnapshot(`Object {}`);
  expect(getTestPluginState(client).count).toBe(0);
  // the first message is already visible cause of the leading debounce
  expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
        Object {
          "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {},
            },
          ],
        }
      `);
  client.flushMessageBuffer();
  expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
        Object {
          "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {},
            },
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {
                "delta": 2,
              },
            },
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {
                "delta": 3,
              },
            },
          ],
        }
      `);

  // process the message
  const pluginKey = getPluginKey(client.id, device, TestPlugin.id);
  await processMessageQueue(
    client.sandyPluginStates.get(TestPlugin.id)!,
    pluginKey,
    store,
  );
  expect(getTestPluginState(client)).toEqual({
    count: 6,
  });

  expect(store.getState().pluginMessageQueue).toEqual({
    [pluginKey]: [],
  });

  // unstar. Messages don't arrive anymore
  starTestPlugin(store, client);
  // weird state...
  selectTestPlugin(store, client);
  sendMessage('inc', {delta: 3});
  client.flushMessageBuffer();
  // active, immediately processed
  expect(client.sandyPluginStates.has(TestPlugin.id)).toBe(false);

  // different plugin, and not starred, message will never arrive
  selectDeviceLogs(store);
  sendMessage('inc', {delta: 4});
  client.flushMessageBuffer();
  expect(store.getState().pluginMessageQueue).toEqual({
    [pluginKey]: [],
  });

  // star again, plugin still not selected, message is queued
  starTestPlugin(store, client);
  sendMessage('inc', {delta: 5});
  client.flushMessageBuffer();

  expect(store.getState().pluginMessageQueue).toEqual({
    [pluginKey]: [{api: 'TestPlugin', method: 'inc', params: {delta: 5}}],
  });
});

test('queue - events are queued for plugins that are favorite when app is not selected', async () => {
  const {
    client,
    device,
    store,
    sendMessage,
    createClient,
  } = await createMockFlipperWithPlugin(TestPlugin);
  selectDeviceLogs(store);
  expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin');

  const client2 = await createClient(device, 'TestApp2');
  store.dispatch(selectClient(client2.id));

  // Now we send a message to the second client, it should arrive,
  // as the plugin was enabled already on the first client as well
  sendMessage('inc', {delta: 2});
  expect(getTestPluginState(client)).toEqual({count: 0});
  expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
          Object {
            "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
              Object {
                "api": "TestPlugin",
                "method": "inc",
                "params": Object {
                  "delta": 2,
                },
              },
            ],
          }
        `);
});

test('queue - events are queued for plugins that are favorite when app is selected on different device', async () => {
  const {
    client,
    store,
    sendMessage,
    createDevice,
    createClient,
  } = await createMockFlipperWithPlugin(TestPlugin);
  selectDeviceLogs(store);
  expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin');

  const device2 = createDevice('serial2');
  const client2 = await createClient(device2, client.query.app); // same app id
  store.dispatch(selectDevice(device2));
  store.dispatch(selectClient(client2.id));

  // Now we send a message to the first and second client, it should arrive,
  // as the plugin was enabled already on the first client as well
  sendMessage('inc', {delta: 2});
  sendMessage('inc', {delta: 3}, client2);
  client.flushMessageBuffer();
  client2.flushMessageBuffer();
  expect(getTestPluginState(client)).toEqual({count: 0});
  expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
    Object {
      "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
        Object {
          "api": "TestPlugin",
          "method": "inc",
          "params": Object {
            "delta": 2,
          },
        },
      ],
      "TestApp#Android#MockAndroidDevice#serial2#TestPlugin": Array [
        Object {
          "api": "TestPlugin",
          "method": "inc",
          "params": Object {
            "delta": 3,
          },
        },
      ],
    }
  `);
});

test('queue - events processing will be paused', async () => {
  const {
    client,
    device,
    store,
    sendMessage,
  } = await createMockFlipperWithPlugin(TestPlugin);
  selectDeviceLogs(store);

  sendMessage('inc', {});
  sendMessage('inc', {delta: 3});
  sendMessage('inc', {delta: 5});
  client.flushMessageBuffer();

  // process the message
  const pluginKey = getPluginKey(client.id, device, TestPlugin.id);

  // controlled idler will signal and and off that idling is needed
  const idler = new TestIdler();

  const p = processMessageQueue(
    client.sandyPluginStates.get(TestPlugin.id)!,
    pluginKey,
    store,
    undefined,
    idler,
  );

  expect(getTestPluginState(client)).toEqual({
    count: 4,
  });

  expect(store.getState().pluginMessageQueue).toEqual({
    [pluginKey]: [{api: 'TestPlugin', method: 'inc', params: {delta: 5}}],
  });

  await idler.next();
  expect(getTestPluginState(client)).toEqual({
    count: 9,
  });

  expect(store.getState().pluginMessageQueue).toEqual({
    [pluginKey]: [],
  });

  // don't idle anymore
  idler.run();
  await p;
});

test('queue - messages that arrive during processing will be queued', async () => {
  const {
    client,
    device,
    store,
    sendMessage,
  } = await createMockFlipperWithPlugin(TestPlugin);
  selectDeviceLogs(store);

  sendMessage('inc', {});
  sendMessage('inc', {delta: 2});
  sendMessage('inc', {delta: 3});
  client.flushMessageBuffer();

  // process the message
  const pluginKey = getPluginKey(client.id, device, TestPlugin.id);

  const idler = new TestIdler();

  const p = processMessageQueue(
    client.sandyPluginStates.get(TestPlugin.id)!,
    pluginKey,
    store,
    undefined,
    idler,
  );

  // first message is consumed
  expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(1);
  expect(getTestPluginState(client).count).toBe(3);

  // Select the current plugin as active, still, messages should end up in the queue
  store.dispatch(
    selectPlugin({
      selectedPlugin: TestPlugin.id,
      selectedApp: client.id,
      deepLinkPayload: null,
      selectedDevice: device,
    }),
  );
  expect(store.getState().connections.selectedPlugin).toBe('TestPlugin');

  sendMessage('inc', {delta: 4});
  client.flushMessageBuffer();
  // should not be processed yet
  expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(2);
  expect(getTestPluginState(client).count).toBe(3);

  await idler.next();
  expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(0);
  expect(getTestPluginState(client).count).toBe(10);

  idler.run();
  await p;
});

test('queue - processing can be cancelled', async () => {
  const {
    client,
    device,
    store,
    sendMessage,
  } = await createMockFlipperWithPlugin(TestPlugin);
  selectDeviceLogs(store);

  sendMessage('inc', {});
  sendMessage('inc', {delta: 2});
  sendMessage('inc', {delta: 3});
  sendMessage('inc', {delta: 4});
  sendMessage('inc', {delta: 5});
  client.flushMessageBuffer();

  // process the message
  const pluginKey = getPluginKey(client.id, device, TestPlugin.id);

  const idler = new TestIdler();

  const p = processMessageQueue(
    client.sandyPluginStates.get(TestPlugin.id)!,
    pluginKey,
    store,
    undefined,
    idler,
  );

  // first message is consumed
  await idler.next();
  expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(1);
  expect(getTestPluginState(client).count).toBe(10);

  idler.cancel();

  expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(1);
  expect(getTestPluginState(client).count).toBe(10);
  await p;
});

test('queue - make sure resetting plugin state clears the message queue', async () => {
  const {
    client,
    device,
    store,
    sendMessage,
  } = await createMockFlipperWithPlugin(TestPlugin);
  selectDeviceLogs(store);

  sendMessage('inc', {});
  sendMessage('inc', {delta: 2});
  client.flushMessageBuffer();

  const pluginKey = getPluginKey(client.id, device, TestPlugin.id);

  expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(2);

  store.dispatch({
    type: 'CLEAR_PLUGIN_STATE',
    payload: {clientId: client.id, devicePlugins: new Set()},
  });

  expect(store.getState().pluginMessageQueue[pluginKey]).toBe(undefined);
});

test('client - incoming messages are buffered and flushed together', async () => {
  class StubDeviceLogs extends FlipperDevicePlugin<any, any, any> {
    static id = 'DevicePlugin';

    static supportsDevice() {
      return true;
    }

    static persistedStateReducer = jest.fn();
  }

  const {
    client,
    store,
    device,
    sendMessage,
    pluginKey,
  } = await createMockFlipperWithPlugin(TestPlugin);
  selectDeviceLogs(store);

  store.dispatch(registerPlugins([StubDeviceLogs]));
  sendMessage('inc', {});
  sendMessage('inc', {delta: 2});
  sendMessage('inc', {delta: 3});

  // send a message to device logs
  client.onMessage(
    JSON.stringify({
      method: 'execute',
      params: {
        api: 'DevicePlugin',
        method: 'log',
        params: {line: 'suff'},
      },
    }),
  );

  expect(store.getState().pluginStates).toMatchInlineSnapshot(`Object {}`);
  expect(getTestPluginState(client).count).toBe(0);
  // the first message is already visible cause of the leading debounce
  expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
        Object {
          "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {},
            },
          ],
        }
      `);
  expect(client.messageBuffer).toMatchInlineSnapshot(`
        Object {
          "TestApp#Android#MockAndroidDevice#serial#DevicePlugin": Object {
            "messages": Array [
              Object {
                "api": "DevicePlugin",
                "method": "log",
                "params": Object {
                  "line": "suff",
                },
              },
            ],
            "plugin": [Function],
          },
          "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Object {
            "messages": Array [
              Object {
                "api": "TestPlugin",
                "method": "inc",
                "params": Object {
                  "delta": 2,
                },
              },
              Object {
                "api": "TestPlugin",
                "method": "inc",
                "params": Object {
                  "delta": 3,
                },
              },
            ],
            "plugin": undefined,
          },
        }
      `);
  expect(client.messageBuffer[pluginKey].plugin).toBeInstanceOf(
    SandyPluginInstance,
  );

  await sleep(500);
  expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
        Object {
          "TestApp#Android#MockAndroidDevice#serial#DevicePlugin": Array [
            Object {
              "api": "DevicePlugin",
              "method": "log",
              "params": Object {
                "line": "suff",
              },
            },
          ],
          "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {},
            },
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {
                "delta": 2,
              },
            },
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {
                "delta": 3,
              },
            },
          ],
        }
      `);
  expect(client.messageBuffer).toMatchInlineSnapshot(`Object {}`);
  expect(StubDeviceLogs.persistedStateReducer.mock.calls).toMatchInlineSnapshot(
    `Array []`,
  );

  // tigger processing the queue
  const pluginKeyDevice = getPluginKey(client.id, device, StubDeviceLogs.id);
  await processMessageQueue(StubDeviceLogs, pluginKeyDevice, store);

  expect(StubDeviceLogs.persistedStateReducer.mock.calls)
    .toMatchInlineSnapshot(`
        Array [
          Array [
            Object {},
            "log",
            Object {
              "line": "suff",
            },
          ],
        ]
      `);

  expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(`
        Object {
          "TestApp#Android#MockAndroidDevice#serial#DevicePlugin": Array [],
          "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {},
            },
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {
                "delta": 2,
              },
            },
            Object {
              "api": "TestPlugin",
              "method": "inc",
              "params": Object {
                "delta": 3,
              },
            },
          ],
        }
      `);
});
