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

#include <folly/Format.h>
#include <folly/dynamic.h>
#include <folly/executors/ManualExecutor.h>
#include <folly/executors/QueuedImmediateExecutor.h>
#include <folly/json.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>

#include <jsinspector-modern/InspectorInterfaces.h>
#include <jsinspector-modern/InspectorPackagerConnection.h>

#include <functional>
#include <memory>

#include "FollyDynamicMatchers.h"
#include "InspectorMocks.h"
#include "UniquePtrFactory.h"

using namespace ::testing;
using namespace std::literals::chrono_literals;
using namespace std::literals::string_literals;
using folly::dynamic, folly::parseJson, folly::toJson, folly::format,
    folly::sformat;

namespace facebook::react::jsinspector_modern {

namespace {

template <typename Executor>
class InspectorPackagerConnectionTestBase : public testing::Test {
 protected:
  InspectorPackagerConnectionTestBase()
      : packagerConnection_(InspectorPackagerConnection{
            "ws://mock-host:12345",
            "my-app",
            packagerConnectionDelegates_.make_unique(asyncExecutor_)}) {
    ON_CALL(*packagerConnectionDelegate(), connectWebSocket(_, _))
        .WillByDefault(webSockets_.lazily_make_unique<
                       const std::string&,
                       std::weak_ptr<IWebSocketDelegate>>());
  }

  void TearDown() override {
    // Forcibly clean up all pages currently registered with the inspector in
    // order to isolate state between tests. NOTE: Using TearDown instead of a
    // destructor so that we can use FAIL() etc.
    std::vector<int> pagesToRemove;
    auto pages = getInspectorInstance().getPages();
    int liveConnectionCount = 0;
    for (size_t i = 0; i != localConnections_.objectsVended(); ++i) {
      if (localConnections_[i]) {
        liveConnectionCount++;
        // localConnections_[i] is a strict mock and will complain when we
        // removePage if the call is unexpected.
        EXPECT_CALL(*localConnections_[i], disconnect());
      }
    }
    for (auto& page : pages) {
      getInspectorInstance().removePage(page.id);
    }
    if (!pages.empty() && liveConnectionCount) {
      if (!::testing::Test::HasFailure()) {
        FAIL()
            << "Test case ended with " << liveConnectionCount
            << " open connection(s) and " << pages.size()
            << " registered page(s). You must manually call removePage for each page.";
      }
    }
    ::testing::Test::TearDown();
  }

  MockInspectorPackagerConnectionDelegate* packagerConnectionDelegate() {
    // We only create one PackagerConnectionDelegate per test.
    EXPECT_EQ(packagerConnectionDelegates_.objectsVended(), 1);
    return packagerConnectionDelegates_[0];
  }

  Executor asyncExecutor_;

  UniquePtrFactory<MockInspectorPackagerConnectionDelegate>
      packagerConnectionDelegates_;
  /**
   * webSockets_ will hold the WebSocket instance(s) owned by
   * packagerConnection_ while also allowing us to access them during
   * the test. We can send messages *to* packagerConnection_ by
   * calling webSockets_[i]->getDelegate().didReceiveMessage(...). Messages
   * *from* packagerConnection_ will be found as calls to
   * webSockets_[i]->send, which is a mock method installed by gmock.
   * These are strict mocks, so method calls will fail if they are not
   * expected with a corresponding call to EXPECT_CALL(...) - for example
   * if unexpected WebSocket messages are sent.
   */
  UniquePtrFactory<StrictMock<MockWebSocket>> webSockets_;
  /**
   * localConnections_ will hold the LocalConnection instances owned
   * by packagerConnection_ while also allowing us to access them
   * during the test.
   * These are strict mocks, so method calls will fail if they are not
   * expected with a corresponding call to EXPECT_CALL(...).
   */
  UniquePtrFactory<StrictMock<MockLocalConnection>> localConnections_;
  std::optional<InspectorPackagerConnection> packagerConnection_;
};

using InspectorPackagerConnectionTest =
    InspectorPackagerConnectionTestBase<folly::QueuedImmediateExecutor>;

/**
 * Fixture class for tests that run on a ManualExecutor. Work scheduled
 * on the executor is *not* run automatically; it must be manually advanced
 * in the body of the test.
 */
class InspectorPackagerConnectionTestAsync
    : public InspectorPackagerConnectionTestBase<folly::ManualExecutor> {
 public:
  virtual void TearDown() override {
    // Assert there are no pending tasks on the ManualExecutor.
    auto tasksCleared = asyncExecutor_.clear();
    EXPECT_EQ(tasksCleared, 0)
        << "There were still pending tasks on asyncExecutor_ at the end of the test. Use advance() or run() as needed.";
    InspectorPackagerConnectionTestBase<folly::ManualExecutor>::TearDown();
  }
};

} // namespace

TEST_F(InspectorPackagerConnectionTest, TestConnectThenDestroy) {
  packagerConnection_->connect();

  // The connection should be established immediately.
  ASSERT_TRUE(webSockets_[0]);
  EXPECT_EQ(webSockets_[0]->url, "ws://mock-host:12345");
  EXPECT_TRUE(packagerConnection_->isConnected());

  // Destroying packagerConnection_ should close the underlying WebSocket (by
  // destroying it).
  packagerConnection_.reset();
  EXPECT_FALSE(webSockets_[0]);
}

TEST_F(InspectorPackagerConnectionTest, TestConnectMultipleTimes) {
  packagerConnection_->connect();

  packagerConnection_->connect();

  // The WebSocket gets recreated and the connection is in a valid state.
  EXPECT_FALSE(webSockets_[0]);
  ASSERT_TRUE(webSockets_[1]);
  EXPECT_EQ(webSockets_[1]->url, "ws://mock-host:12345");
  EXPECT_TRUE(packagerConnection_->isConnected());

  // Destroying packagerConnection_ should close the underlying WebSocket (by
  // destroying it).
  packagerConnection_.reset();
  EXPECT_FALSE(webSockets_[1]);
}

TEST_F(InspectorPackagerConnectionTest, TestCloseQuietly) {
  packagerConnection_->connect();

  ASSERT_TRUE(webSockets_[0]);
  EXPECT_TRUE(packagerConnection_->isConnected());

  packagerConnection_->closeQuietly();
  EXPECT_FALSE(packagerConnection_->isConnected());
  EXPECT_FALSE(webSockets_[0]);

  // Calling closeQuietly again has no effect.
  packagerConnection_->closeQuietly();
  EXPECT_FALSE(packagerConnection_->isConnected());
  EXPECT_FALSE(webSockets_[0]);

  // Connecting again is a noop (except for logging an error).
  packagerConnection_->connect();
  EXPECT_FALSE(packagerConnection_->isConnected());
  EXPECT_FALSE(webSockets_[0]);
}

TEST_F(InspectorPackagerConnectionTest, TestGetPages) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();

  // The list of pages is empty at first.
  EXPECT_CALL(*webSockets_[0], send(JsonEq(R"({
      "event": "getPages",
      "payload": []
    })")))
      .RetiresOnSaturation();
  webSockets_[0]->getDelegate().didReceiveMessage(R"({
      "event": "getPages"
    })");

  auto pageId1 = getInspectorInstance().addPage(
      "mock-title-1",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>(),
      {.nativePageReloads = true});

  auto pageId2 = getInspectorInstance().addPage(
      "mock-title-2",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>(),
      {.nativePageReloads = true});

  // getPages now reports the page we registered, in the order added
  EXPECT_CALL(
      *webSockets_[0],
      send(JsonParsed(AllOf(
          AtJsonPtr("/event", Eq("getPages")),
          AtJsonPtr(
              "/payload",
              ElementsAreArray(
                  {AllOf(
                       AtJsonPtr("/app", Eq("my-app")),
                       AtJsonPtr("/title", Eq("mock-title-1 [C++ connection]")),
                       AtJsonPtr("/id", Eq(std::to_string(pageId1))),
                       AtJsonPtr("/capabilities/nativePageReloads", Eq(true)),
                       AtJsonPtr(
                           "/capabilities/nativeSourceCodeFetching",
                           Eq(false))),
                   AllOf(
                       AtJsonPtr("/app", Eq("my-app")),
                       AtJsonPtr("/title", Eq("mock-title-2 [C++ connection]")),
                       AtJsonPtr("/id", Eq(std::to_string(pageId2))),
                       AtJsonPtr("/capabilities/nativePageReloads", Eq(true)),
                       AtJsonPtr(
                           "/capabilities/nativeSourceCodeFetching",
                           Eq(false)))}))))))
      .RetiresOnSaturation();
  webSockets_[0]->getDelegate().didReceiveMessage(R"({
      "event": "getPages"
    })");

  getInspectorInstance().removePage(pageId1);
  getInspectorInstance().removePage(pageId2);

  // getPages is back to reporting no pages.
  EXPECT_CALL(
      *webSockets_[0],
      send(JsonEq(
          R"({
              "event": "getPages",
              "payload": []
            })")))
      .RetiresOnSaturation();
  webSockets_[0]->getDelegate().didReceiveMessage(R"({
      "event": "getPages"
    })");
}

TEST_F(InspectorPackagerConnectionTest, TestSendReceiveEvents) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Send an event from the mocked backend (local) to the frontend (remote)
  // and observe it being sent via the socket.
  EXPECT_CALL(
      *webSockets_[0],
      send(JsonParsed(AllOf(
          AtJsonPtr("/event", Eq("wrappedEvent")),
          AtJsonPtr("/payload/pageId", Eq(std::to_string(pageId))),
          AtJsonPtr(
              "/payload/wrappedEvent",
              JsonEq(
                  R"({
                    "method": "FakeDomain.eventTriggered",
                    "params": ["arg1", "arg2"]
                  })"))))))
      .RetiresOnSaturation();
  localConnections_[0]->getRemoteConnection().onMessage(R"({
                                                            "method": "FakeDomain.eventTriggered",
                                                            "params": ["arg1", "arg2"]
                                                          })");

  // Send an event from the frontend (remote) to the backend (local) and
  // observe it being received by localConnection.
  EXPECT_CALL(
      *localConnections_[0],
      sendMessage(JsonParsed(AllOf(
          AtJsonPtr("/method", Eq("FakeDomain.fakeMethod")),
          AtJsonPtr("/id", Eq(1234)),
          AtJsonPtr("/params", ElementsAre("arg1", "arg2"))))))
      .RetiresOnSaturation();

  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "wrappedEvent",
          "payload": {{
            "pageId": {0},
            "wrappedEvent": {1}
          }}
        }})",
      toJson(std::to_string(pageId)),
      toJson(R"({
                "method": "FakeDomain.fakeMethod",
                "id": 1234,
                "params": ["arg1", "arg2"]
              })")));

  // Send a 'disconnect' event from the mocked backend (local) to the frontend
  // (remote) and observe it being sent via the socket.
  EXPECT_CALL(
      *webSockets_[0],
      send(JsonParsed(AllOf(
          AtJsonPtr("/event", Eq("disconnect")),
          AtJsonPtr("/payload/pageId", Eq(std::to_string(pageId)))))))
      .RetiresOnSaturation();
  localConnections_[0]->getRemoteConnection().onDisconnect();

  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  getInspectorInstance().removePage(pageId);
}

TEST_F(InspectorPackagerConnectionTest, TestSendReceiveEventsToMultiplePages) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();

  std::vector<int> pageIds;

  const int kNumPages = 2;
  for (int i = 0; i < kNumPages; ++i) {
    pageIds.push_back(getInspectorInstance().addPage(
        "mock-title",
        "mock-vm",
        localConnections_
            .lazily_make_unique<std::unique_ptr<IRemoteConnection>>()));
    if (i > 0) {
      ASSERT_NE(pageIds[i], pageIds[i - 1])
          << "Received duplicate page IDs from inspector.";
    }
  }

  for (int i = 0; i < kNumPages; ++i) {
    // Connect to the i-th page.
    webSockets_[0]->getDelegate().didReceiveMessage(sformat(
        R"({{
        "event": "connect",
        "payload": {{
          "pageId": {0}
        }}
      }})",
        toJson(std::to_string(pageIds[i]))));
    ASSERT_TRUE(localConnections_[i]);
  }

  // Send an event from each LocalConnection and observe it being sent via
  // the socket.
  for (int i = 0; i < kNumPages; ++i) {
    // Generate a unique method name for this page to validate that we are
    // routing the events correctly.
    std::string method =
        "FakeDomain.eventFromPage"s + std::to_string(pageIds[i]);
    EXPECT_CALL(
        *webSockets_[0],
        send(JsonParsed(AllOf(
            AtJsonPtr("/event", Eq("wrappedEvent")),
            AtJsonPtr("/payload/pageId", Eq(std::to_string(pageIds[i]))),
            AtJsonPtr(
                "/payload/wrappedEvent",
                JsonParsed(AtJsonPtr("/method", Eq(method))))))))
        .RetiresOnSaturation();
    localConnections_[i]->getRemoteConnection().onMessage(
        toJson(dynamic::object("method", method)));
  }

  // Send an event from the frontend (remote) to the backend (local) and
  // observe it being received by each LocalConnection.
  for (int i = 0; i < kNumPages; ++i) {
    // Generate a unique method name for this page to validate that we are
    // routing the events correctly.
    std::string method =
        "FakeDomain.methodToPage"s + std::to_string(pageIds[i]);
    EXPECT_CALL(
        *localConnections_[i],
        sendMessage(JsonParsed(AtJsonPtr("/method", Eq(method)))))
        .RetiresOnSaturation();
    webSockets_[0]->getDelegate().didReceiveMessage(sformat(
        R"({{
          "event": "wrappedEvent",
          "payload": {{
            "pageId": {0},
            "wrappedEvent": {1}
          }}
        }})",
        toJson(std::to_string(pageIds[i])),
        toJson(toJson(dynamic::object("method", method)))));
  }

  for (int i = 0; i < kNumPages; ++i) {
    EXPECT_CALL(*localConnections_[i], disconnect()).RetiresOnSaturation();
    getInspectorInstance().removePage(pageIds[i]);
  }
}

TEST_F(InspectorPackagerConnectionTest, TestSendEventToAllConnections) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Impersonate the frontend (remote) to send a message to all (local)
  // connections.
  EXPECT_CALL(
      *localConnections_[0],
      sendMessage(JsonParsed(AllOf(
          AtJsonPtr("/method", Eq("FakeDomain.fakeMethod")),
          AtJsonPtr("/id", Eq(1234)),
          AtJsonPtr("/params", ElementsAre("arg1", "arg2"))))))
      .RetiresOnSaturation();
  packagerConnection_->sendEventToAllConnections(R"({
    "method": "FakeDomain.fakeMethod",
    "id": 1234,
    "params": ["arg1", "arg2"]
  })");

  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  getInspectorInstance().removePage(pageId);
}

TEST_F(InspectorPackagerConnectionTest, TestConnectThenDisconnect) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Disconnect from the page.
  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "disconnect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  EXPECT_FALSE(localConnections_[0]);
}

TEST_F(InspectorPackagerConnectionTest, TestConnectThenCloseSocket) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Notify that the socket was closed.
  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  webSockets_[0]->getDelegate().didClose();
  EXPECT_FALSE(localConnections_[0]);
}

TEST_F(InspectorPackagerConnectionTest, TestConnectThenSocketFailure) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Notify that the socket was closed (implicitly, as the result of an error).
  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  webSockets_[0]->getDelegate().didFailWithError(ECONNABORTED, "Test error");
  EXPECT_FALSE(localConnections_[0]);
}

TEST_F(
    InspectorPackagerConnectionTestAsync,
    TestExplicitCloseAfterSocketFailure) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Notify that the socket was closed (implicitly, as the result of an error).
  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();

  {
    // The WebSocket instance gets destroyed during didFailWithError, so extract
    // the delegate in order to call didClose.
    std::shared_ptr webSocketDelegate = webSockets_[0]->delegate.lock();

    webSocketDelegate->didFailWithError(ECONNABORTED, "Test error");
    webSocketDelegate->didClose();
  }

  EXPECT_FALSE(localConnections_[0]);
  // We're still disconnected since we haven't called the reconnect callback.
  EXPECT_FALSE(packagerConnection_->isConnected());

  // Flush the callback queue.
  asyncExecutor_.advance(2000ms);

  EXPECT_TRUE(packagerConnection_->isConnected());
}

TEST_F(
    InspectorPackagerConnectionTest,
    TestConnectWhileAlreadyConnectedCausesDisconnection) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Try connecting to the same page again. This results in a disconnection.
  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  EXPECT_FALSE(localConnections_[0]);
}

TEST_F(InspectorPackagerConnectionTest, TestMultipleDisconnect) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Disconnect from the page.
  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "disconnect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  EXPECT_FALSE(localConnections_[0]);

  // Disconnect again. This is a noop.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "disconnect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  EXPECT_FALSE(localConnections_[0]);
}

TEST_F(InspectorPackagerConnectionTest, TestDisconnectThenSendEvent) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Disconnect from the page.
  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "disconnect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  EXPECT_FALSE(localConnections_[0]);

  // Send an event from the frontend (remote) to the backend (local). This
  // is a noop.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "wrappedEvent",
          "payload": {{
            "pageId": {0},
            "wrappedEvent": {1}
          }}
        }})",
      toJson(std::to_string(pageId)),
      toJson(R"({
                "method": "FakeDomain.fakeMethod",
                "id": 1234,
                "params": ["arg1", "arg2"]
              })")));
}

TEST_F(InspectorPackagerConnectionTest, TestSendEventToUnknownPage) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();

  // Send an event from the frontend (remote) to the backend (local). This
  // is a noop (except for logging).
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "wrappedEvent",
          "payload": {{
            "pageId": "1234",
            "wrappedEvent": {0}
          }}
        }})",
      toJson(R"({
                "method": "FakeDomain.fakeMethod",
                "id": 1234,
                "params": ["arg1", "arg2"]
              })")));
}

TEST_F(InspectorPackagerConnectionTest, TestReconnectSuccessful) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);
  EXPECT_CALL(*packagerConnectionDelegate(), scheduleCallback(_, _))
      .RetiresOnSaturation();
  webSockets_[0]->getDelegate().didClose();
  EXPECT_FALSE(webSockets_[0]);
  EXPECT_TRUE(webSockets_[1]);
  EXPECT_TRUE(packagerConnection_->isConnected());

  // Stops attempting to reconnect after closeQuietly

  packagerConnection_->closeQuietly();
}

TEST_F(InspectorPackagerConnectionTest, TestReconnectFailure) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);
  EXPECT_CALL(*packagerConnectionDelegate(), scheduleCallback(_, _))
      .Times(2)
      .RetiresOnSaturation();

  webSockets_[0]->getDelegate().didClose();
  EXPECT_FALSE(webSockets_[0]);
  ASSERT_TRUE(webSockets_[1]);
  webSockets_[1]->getDelegate().didClose();
  EXPECT_FALSE(webSockets_[1]);
  ASSERT_TRUE(webSockets_[2]);
  EXPECT_TRUE(packagerConnection_->isConnected());

  // Stops attempting to reconnect after closeQuietly

  packagerConnection_->closeQuietly();

  EXPECT_FALSE(webSockets_[2]);
  EXPECT_FALSE(packagerConnection_->isConnected());
}

TEST_F(InspectorPackagerConnectionTest, TestReconnectOnSocketError) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);
  EXPECT_CALL(*packagerConnectionDelegate(), scheduleCallback(_, _))
      .RetiresOnSaturation();
  webSockets_[0]->getDelegate().didFailWithError(ECONNRESET, "Test error");
  EXPECT_FALSE(webSockets_[0]);
  EXPECT_TRUE(webSockets_[1]);
  EXPECT_TRUE(packagerConnection_->isConnected());

  // Stops attempting to reconnect after closeQuietly

  packagerConnection_->closeQuietly();
}

TEST_F(InspectorPackagerConnectionTest, TestReconnectOnSocketErrorWithNoCode) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);
  EXPECT_CALL(*packagerConnectionDelegate(), scheduleCallback(_, _))
      .RetiresOnSaturation();
  webSockets_[0]->getDelegate().didFailWithError(std::nullopt, "Test error");
  EXPECT_FALSE(webSockets_[0]);
  EXPECT_TRUE(webSockets_[1]);
  EXPECT_TRUE(packagerConnection_->isConnected());

  // Stops attempting to reconnect after closeQuietly

  packagerConnection_->closeQuietly();
}

TEST_F(InspectorPackagerConnectionTest, TestNoReconnectOnConnectionRefused) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);
  webSockets_[0]->getDelegate().didFailWithError(ECONNREFUSED, "Test error");
  EXPECT_FALSE(webSockets_[0]);
  EXPECT_FALSE(packagerConnection_->isConnected());
}

TEST_F(InspectorPackagerConnectionTest, TestUnknownEvent) {
  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);

  // This is a noop (other than logging an error).
  webSockets_[0]->getDelegate().didReceiveMessage(R"({"event": "foo"})");
}

TEST_F(InspectorPackagerConnectionTest, TestMalformedEvent) {
  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);

  // This is a noop (other than logging an error).
  webSockets_[0]->getDelegate().didReceiveMessage("this is not json");
  webSockets_[0]->getDelegate().didReceiveMessage("{");
  webSockets_[0]->getDelegate().didReceiveMessage("");
}

TEST_F(InspectorPackagerConnectionTest, TestEventsNotConformingToType) {
  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);

  // These are all noops (other than logging an error).
  webSockets_[0]->getDelegate().didReceiveMessage(R"({})");
  webSockets_[0]->getDelegate().didReceiveMessage(
      R"({"event": "wrappedEvent"})");
  webSockets_[0]->getDelegate().didReceiveMessage(R"({"event": "connect"})");
  webSockets_[0]->getDelegate().didReceiveMessage(R"({"event": "disconnect"})");
  webSockets_[0]->getDelegate().didReceiveMessage(
      R"({"payload": {"pageId": "1"}})");
}

TEST_F(
    InspectorPackagerConnectionTest,
    TestWebSocketDelegateIsDestroyedWithConnectionByDefault) {
  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);

  std::weak_ptr delegate = webSockets_[0]->delegate;
  EXPECT_TRUE(delegate.lock());

  packagerConnection_.reset();
  EXPECT_FALSE(delegate.lock());
}

// Edge case: When the C++ layer has released the InspectorPackagerConnection,
// the platform bindings can still call methods on IWebSocketDelegate through
// a shared_ptr (typically _briefly_ upgraded from the weak_ptr we provide).
TEST_F(
    InspectorPackagerConnectionTest,
    TestWebSocketDelegateCanOutliveConnection) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);

  std::shared_ptr retainedWebSocketDelegate = webSockets_[0]->delegate.lock();
  ASSERT_TRUE(retainedWebSocketDelegate);

  // Destroy our InspectorPackagerConnection. We can't call methods on it
  // anymore, but its internals are still valid and it is still responding to
  // socket messages.
  packagerConnection_.reset();

  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  retainedWebSocketDelegate->didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Send an event from the frontend (remote) to the backend (local) and
  // observe it being received by localConnection.
  EXPECT_CALL(
      *localConnections_[0],
      sendMessage(JsonParsed(AllOf(
          AtJsonPtr("/method", Eq("FakeDomain.fakeMethod")),
          AtJsonPtr("/id", Eq(1234)),
          AtJsonPtr("/params", ElementsAre("arg1", "arg2"))))))
      .RetiresOnSaturation();

  retainedWebSocketDelegate->didReceiveMessage(sformat(
      R"({{
          "event": "wrappedEvent",
          "payload": {{
            "pageId": {0},
            "wrappedEvent": {1}
          }}
        }})",
      toJson(std::to_string(pageId)),
      toJson(R"({
                "method": "FakeDomain.fakeMethod",
                "id": 1234,
                "params": ["arg1", "arg2"]
              })")));

  retainedWebSocketDelegate.reset();
  EXPECT_FALSE(localConnections_[0]);
  EXPECT_FALSE(webSockets_[0]);
}

TEST_F(InspectorPackagerConnectionTest, TestDestroyConnectionOnPageRemoved) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  ASSERT_TRUE(webSockets_[0]);

  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  EXPECT_TRUE(localConnections_[0]);

  // Remove the page.
  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  getInspectorInstance().removePage(pageId);
  EXPECT_FALSE(localConnections_[0]);
}

TEST_F(
    InspectorPackagerConnectionTestAsync,
    TestAttemptSendToRemoteAfterDestroyed) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Send an event from the mocked backend (local) to the frontend (remote)
  // but don't flush the callback queue yet.
  localConnections_[0]->getRemoteConnection().onMessage(R"({
                                                            "method": "FakeDomain.eventTriggered",
                                                            "params": ["arg1", "arg2"]
                                                          })");

  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  getInspectorInstance().removePage(pageId);

  packagerConnection_.reset();

  // Flush the callback queue. This doesn't crash.
  EXPECT_EQ(asyncExecutor_.run(), 1);
}

TEST_F(
    InspectorPackagerConnectionTestAsync,
    TestAttemptSendToStaleRemoteConnection) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Send an event from the mocked backend (local) to the frontend (remote)
  // but don't flush the callback queue yet.
  localConnections_[0]->getRemoteConnection().onMessage(R"({
                                                            "method": "FakeDomain.eventToBeDropped",
                                                            "params": ["arg1", "arg2"]
                                                          })");

  // Disconnect from the page.
  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "disconnect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  EXPECT_FALSE(localConnections_[0]);

  // Connect to the same page again.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));

  EXPECT_TRUE(localConnections_[1]);

  // Send an event from the mocked backend (local) to the frontend (remote) over
  // the new connection, then flush the callback queue.
  // Only this event should be sent over the socket.
  EXPECT_CALL(
      *webSockets_[0],
      send(JsonParsed(AllOf(
          AtJsonPtr("/event", Eq("wrappedEvent")),
          AtJsonPtr("/payload/pageId", Eq(std::to_string(pageId))),
          AtJsonPtr(
              "/payload/wrappedEvent",
              JsonEq(
                  R"({
                    "method": "FakeDomain.eventToBeDelivered",
                    "params": ["arg1", "arg2"]
                  })"))))))
      .RetiresOnSaturation();
  localConnections_[1]->getRemoteConnection().onMessage(R"({
                                                            "method": "FakeDomain.eventToBeDelivered",
                                                            "params": ["arg1", "arg2"]
                                                          })");
  EXPECT_EQ(asyncExecutor_.run(), 2);

  // Clean up.
  EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation();
  getInspectorInstance().removePage(pageId);
}

TEST_F(
    InspectorPackagerConnectionTestAsync,
    TestAttemptSendToStaleRemoteConnectionWhenRetained) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  packagerConnection_->connect();
  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      localConnections_
          .lazily_make_unique<std::unique_ptr<IRemoteConnection>>());

  // Connect to the page.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  ASSERT_TRUE(localConnections_[0]);

  // Send an event from the mocked backend (local) to the frontend (remote)
  // but don't flush the callback queue yet.
  localConnections_[0]->getRemoteConnection().onMessage(R"({
                                                            "method": "FakeDomain.eventToBeDropped",
                                                            "params": ["arg1", "arg2"]
                                                          })");

  // Forcibly retain the remote connection beyond localConnections_[0]'s
  // lifetime.
  auto retainedRemoteConnection0 =
      localConnections_[0]->dangerouslyReleaseRemoteConnection();

  // Disconnect from the page.
  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "disconnect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));
  EXPECT_FALSE(localConnections_[0]);

  // Connect to the same page again.
  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));

  EXPECT_TRUE(localConnections_[1]);

  // Remember localConnections_[0]'s remote connection? We can still use it
  // without crashing, but it will not deliver any messages.
  retainedRemoteConnection0->onMessage(R"({
                                           "method": "FakeDomain.anotherEventToBeDropped",
                                           "params": ["arg1", "arg2"]
                                         })");
  retainedRemoteConnection0->onDisconnect();

  // Send events from the mocked backend (local) to the frontend (remote) over
  // the new connection, then flush the callback queue.
  // Only these events should be sent over the socket.
  EXPECT_CALL(
      *webSockets_[0],
      send(JsonParsed(AllOf(
          AtJsonPtr("/event", Eq("wrappedEvent")),
          AtJsonPtr("/payload/pageId", Eq(std::to_string(pageId))),
          AtJsonPtr(
              "/payload/wrappedEvent",
              JsonEq(
                  R"({
                    "method": "FakeDomain.eventToBeDelivered",
                    "params": ["arg1", "arg2"]
                  })"))))))
      .RetiresOnSaturation();
  localConnections_[1]->getRemoteConnection().onMessage(R"({
                                                            "method": "FakeDomain.eventToBeDelivered",
                                                            "params": ["arg1", "arg2"]
                                                          })");
  EXPECT_CALL(
      *webSockets_[0],
      send(JsonParsed(AllOf(
          AtJsonPtr("/event", Eq("disconnect")),
          AtJsonPtr("/payload/pageId", Eq(std::to_string(pageId)))))))
      .RetiresOnSaturation();
  localConnections_[1]->getRemoteConnection().onDisconnect();

  EXPECT_EQ(asyncExecutor_.run(), 5);

  // Clean up.
  EXPECT_CALL(*localConnections_[1], disconnect()).RetiresOnSaturation();
  getInspectorInstance().removePage(pageId);
}

TEST_F(InspectorPackagerConnectionTest, TestRejectedPageConnection) {
  // Configure gmock to expect calls in a specific order.
  InSequence mockCallsMustBeInSequence;

  enum {
    Accept,
    RejectSilently,
    RejectWithDisconnect
  } mockNextConnectionBehavior;

  auto pageId = getInspectorInstance().addPage(
      "mock-title",
      "mock-vm",
      [&mockNextConnectionBehavior,
       this](auto remoteConnection) -> std::unique_ptr<ILocalConnection> {
        switch (mockNextConnectionBehavior) {
          case Accept:
            return localConnections_.make_unique(std::move(remoteConnection));
          case RejectSilently:
            return nullptr;
          case RejectWithDisconnect:
            remoteConnection->onDisconnect();
            return nullptr;
        }
      });

  packagerConnection_->connect();

  ASSERT_TRUE(webSockets_[0]);

  // Reject the connection by returning nullptr.
  mockNextConnectionBehavior = RejectSilently;

  EXPECT_CALL(
      *webSockets_[0],
      send(JsonParsed(AllOf(
          AtJsonPtr("/event", Eq("disconnect")),
          AtJsonPtr("/payload/pageId", Eq(std::to_string(pageId)))))))
      .RetiresOnSaturation();

  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));

  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "wrappedEvent",
          "payload": {{
            "pageId": {0},
            "wrappedEvent": {1}
          }}
        }})",
      toJson(std::to_string(pageId)),
      toJson(R"({
                "method": "FakeDomain.fakeMethod",
                "id": 1,
                "params": ["arg1", "arg2"]
              })")));

  // Reject the connection by explicitly calling onDisconnect(), then returning
  // nullptr.
  mockNextConnectionBehavior = RejectWithDisconnect;

  EXPECT_CALL(
      *webSockets_[0],
      send(JsonParsed(AllOf(
          AtJsonPtr("/event", Eq("disconnect")),
          AtJsonPtr("/payload/pageId", Eq(std::to_string(pageId)))))))
      .RetiresOnSaturation();

  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));

  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "wrappedEvent",
          "payload": {{
            "pageId": {0},
            "wrappedEvent": {1}
          }}
        }})",
      toJson(std::to_string(pageId)),
      toJson(R"({
                "method": "FakeDomain.fakeMethod",
                "id": 2,
                "params": ["arg1", "arg2"]
              })")));

  // Accept a connection after previously rejecting connections to the same
  // page.
  mockNextConnectionBehavior = Accept;

  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "connect",
          "payload": {{
            "pageId": {0}
          }}
        }})",
      toJson(std::to_string(pageId))));

  EXPECT_CALL(
      *localConnections_[0],
      sendMessage(JsonParsed(AllOf(
          AtJsonPtr("/method", Eq("FakeDomain.fakeMethod")),
          AtJsonPtr("/id", Eq(3)),
          AtJsonPtr("/params", ElementsAre("arg1", "arg2"))))))
      .RetiresOnSaturation();

  webSockets_[0]->getDelegate().didReceiveMessage(sformat(
      R"({{
          "event": "wrappedEvent",
          "payload": {{
            "pageId": {0},
            "wrappedEvent": {1}
          }}
        }})",
      toJson(std::to_string(pageId)),
      toJson(R"({
                "method": "FakeDomain.fakeMethod",
                "id": 3,
                "params": ["arg1", "arg2"]
              })")));

  EXPECT_CALL(*localConnections_[0], disconnect()).RetiresOnSaturation();
  getInspectorInstance().removePage(pageId);
}

} // namespace facebook::react::jsinspector_modern
