// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include <memory>
#include <string>
#include <utility>

#include "base/memory/raw_ptr.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/bind.h"
#include "base/test/simple_test_tick_clock.h"
#include "base/test/values_test_util.h"
#include "build/build_config.h"
#include "chrome/app/chrome_command_ids.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/chrome_content_browser_client.h"
#include "chrome/browser/device_notifications/device_pinned_notification_renderer.h"
#include "chrome/browser/device_notifications/device_status_icon_renderer.h"
#include "chrome/browser/hid/chrome_hid_delegate.h"
#include "chrome/browser/hid/hid_chooser_context.h"
#include "chrome/browser/hid/hid_chooser_context_factory.h"
#include "chrome/browser/hid/hid_connection_tracker.h"
#include "chrome/browser/notifications/notification_display_service_tester.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/common/pref_names.h"
#include "chrome/common/webui_url_constants.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/mixin_based_in_process_browser_test.h"
#include "components/prefs/pref_service.h"
#include "content/public/browser/console_message.h"
#include "content/public/browser/service_worker_context.h"
#include "content/public/browser/service_worker_context_observer.h"
#include "content/public/browser/service_worker_running_info.h"
#include "content/public/browser/storage_partition.h"
#include "content/public/common/content_client.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/service_worker_test_helpers.h"
#include "services/device/public/cpp/test/fake_hid_manager.h"
#include "services/device/public/mojom/hid.mojom.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/hid/hid.mojom.h"

#if BUILDFLAG(ENABLE_EXTENSIONS)
#include "chrome/browser/extensions/extension_browsertest.h"
#include "extensions/common/extension.h"
#include "extensions/test/extension_test_message_listener.h"
#include "extensions/test/result_catcher.h"
#include "extensions/test/test_extension_dir.h"
#endif  // BUILDFLAG(ENABLE_EXTENSIONS)

#if BUILDFLAG(IS_CHROMEOS)
#include "chrome/browser/ash/test/regular_logged_in_browser_test_mixin.h"
#include "components/account_id/account_id.h"
#include "components/account_id/account_id_literal.h"  // nogncheck
#include "components/user_manager/user_manager.h"
#endif  // BUILDFLAG(IS_CHROMEOS)

#if BUILDFLAG(IS_CHROMEOS)
#include "chrome/browser/hid/hid_pinned_notification.h"
#else
#include "chrome/browser/hid/hid_status_icon.h"
#endif  // BUILDFLAG(IS_CHROMEOS)

namespace {

using ::testing::Return;

#if BUILDFLAG(ENABLE_EXTENSIONS)
using ::extensions::Extension;
using ::extensions::ExtensionId;
using ::extensions::TestExtensionDir;

const char kTestExtensionId[] = "iegclhlplifhodhkoafiokenjoapiobj";
// Key for extension id `kTestExtensionId`.
constexpr const char kTestExtensionKey[] =
    "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjzv7dI7Ygyh67VHE1DdidudpYf8P"
    "Ffv8iucWvzO+3xpF/Dm5xNo7aQhPNiEaNfHwJQ7lsp4gc+C+4bbaVewBFspTruoSJhZc5uEf"
    "qxwovJwN+v1/SUFXTXQmQBv6gs0qZB4gBbl4caNQBlqrFwAMNisnu1V6UROna8rOJQ90D7Nv"
    "7TCwoVPKBfVshpFjdDOTeBg4iLctO3S/06QYqaTDrwVceSyHkVkvzBY6tc6mnYX0RZu78J9i"
    "L8bdqwfllOhs69cqoHHgrLdI6JdOyiuh6pBP6vxMlzSKWJ3YTNjaQTPwfOYaLMuzdl0v+Ydz"
    "afIzV9zwe4Xiskk+5JNGt8b2rQIDAQAB";

// Observer for an extension service worker events like start, activated, and
// stop.
class TestServiceWorkerContextObserver
    : public content::ServiceWorkerContextObserver {
 public:
  TestServiceWorkerContextObserver(content::ServiceWorkerContext* context,
                                   const ExtensionId& extension_id)
      : extension_url_(Extension::GetBaseURLFromExtensionId(extension_id)) {
    scoped_observation_.Observe(context);
  }

  TestServiceWorkerContextObserver(const TestServiceWorkerContextObserver&) =
      delete;
  TestServiceWorkerContextObserver& operator=(
      const TestServiceWorkerContextObserver&) = delete;

  ~TestServiceWorkerContextObserver() override = default;

  void WaitForWorkerStart() {
    started_run_loop_.Run();
    EXPECT_TRUE(running_version_id_.has_value());
  }

  void WaitForWorkerActivated() {
    activated_run_loop_.Run();
    EXPECT_TRUE(running_version_id_.has_value());
  }

  void WaitForWorkerStop() {
    stopped_run_loop_.Run();
    EXPECT_EQ(running_version_id_, std::nullopt);
  }

  int64_t GetServiceWorkerVersionId() { return running_version_id_.value(); }

 private:
  // ServiceWorkerContextObserver:
  void OnVersionStartedRunning(
      int64_t version_id,
      const content::ServiceWorkerRunningInfo& running_info) override {
    if (running_info.scope != extension_url_) {
      return;
    }
    running_version_id_ = version_id;
    started_run_loop_.Quit();
  }

  void OnVersionActivated(int64_t version_id, const GURL& scope) override {
    if (running_version_id_ != version_id) {
      return;
    }
    activated_run_loop_.Quit();
  }

  void OnVersionStoppedRunning(int64_t version_id) override {
    if (running_version_id_ != version_id) {
      return;
    }
    stopped_run_loop_.Quit();
    running_version_id_ = std::nullopt;
  }

  void OnDestruct(content::ServiceWorkerContext* context) override {
    ASSERT_TRUE(scoped_observation_.IsObserving());
    scoped_observation_.Reset();
  }

  base::RunLoop started_run_loop_;
  base::RunLoop activated_run_loop_;
  base::RunLoop stopped_run_loop_;
  std::optional<int64_t> running_version_id_;
  base::ScopedObservation<content::ServiceWorkerContext,
                          content::ServiceWorkerContextObserver>
      scoped_observation_{this};
  GURL extension_url_;
};

class TestServiceWorkerConsoleObserver
    : public content::ServiceWorkerContextObserver {
 public:
  explicit TestServiceWorkerConsoleObserver(
      content::BrowserContext* browser_context) {
    content::StoragePartition* partition =
        browser_context->GetDefaultStoragePartition();
    scoped_observation_.Observe(partition->GetServiceWorkerContext());
  }
  ~TestServiceWorkerConsoleObserver() override = default;

  TestServiceWorkerConsoleObserver(const TestServiceWorkerConsoleObserver&) =
      delete;
  TestServiceWorkerConsoleObserver& operator=(
      const TestServiceWorkerConsoleObserver&) = delete;

  using Message = content::ConsoleMessage;
  const std::vector<Message>& messages() const { return messages_; }

  void WaitForMessages() { run_loop_.Run(); }

 private:
  // ServiceWorkerContextObserver:
  void OnReportConsoleMessage(int64_t version_id,
                              const GURL& scope,
                              const Message& message) override {
    messages_.push_back(message);
    run_loop_.Quit();
  }

  base::RunLoop run_loop_;
  std::vector<Message> messages_;
  base::ScopedObservation<content::ServiceWorkerContext,
                          content::ServiceWorkerContextObserver>
      scoped_observation_{this};
};

#if BUILDFLAG(IS_CHROMEOS)
constexpr auto kManagedUserAccountId =
    AccountId::Literal::FromUserEmailGaiaId("example@example.com",
                                            GaiaId::Literal("12345"));
#endif  // BUILDFLAG(IS_CHROMEOS)
#endif  // BUILDFLAG(ENABLE_EXTENSIONS)

// Need to fill it with an url.
constexpr char kPolicySetting[] = R"(
    [
      {
        "devices": [{ "vendor_id": 1234, "product_id": 5678 }],
        "urls": ["%s"]
      }
    ])";

// Create a device with a single collection containing an input report and an
// output report. Both reports have report ID 0.
device::mojom::HidDeviceInfoPtr CreateTestDeviceWithInputAndOutputReports() {
  auto collection = device::mojom::HidCollectionInfo::New();
  collection->usage = device::mojom::HidUsageAndPage::New(0x0001, 0xff00);
  collection->input_reports.push_back(
      device::mojom::HidReportDescription::New());
  collection->output_reports.push_back(
      device::mojom::HidReportDescription::New());

  auto device = device::mojom::HidDeviceInfo::New();
  device->guid = "test-guid";
  device->collections.push_back(std::move(collection));
  // vendor_id and product_id needs to match setting in kPolicySetting
  device->vendor_id = 1234;
  device->product_id = 5678;

  return device;
}

}  // namespace

#if BUILDFLAG(ENABLE_EXTENSIONS)
// Base Test fixture with kEnableWebHidOnExtensionServiceWorker default
// disabled.
class WebHidExtensionBrowserTest : public InProcessBrowserTestMixinHostSupport<
                                       extensions::ExtensionBrowserTest> {
 public:
  WebHidExtensionBrowserTest() {
#if BUILDFLAG(IS_CHROMEOS)
    // The user is created via RegularLoggedInBrowserTestMixin.
    set_chromeos_user_ = false;
#endif  // BUILDFLAG(IS_CHROMEOS)
  }

  void SetUpOnMainThread() override {
    InProcessBrowserTestMixinHostSupport<
        extensions::ExtensionBrowserTest>::SetUpOnMainThread();
#if BUILDFLAG(IS_CHROMEOS)
    user_manager::UserManager::Get()->SetUserPolicyStatus(
        kManagedUserAccountId, /*is_managed=*/true, /*is_affiliated=*/true);
    display_service_for_system_notification_ =
        std::make_unique<NotificationDisplayServiceTester>(
            /*profile=*/nullptr);
#endif  // BUILDFLAG(IS_CHROMEOS)

    mojo::PendingRemote<device::mojom::HidManager> hid_manager;
    hid_manager_.Bind(hid_manager.InitWithNewPipeAndPassReceiver());

    // Connect the HidManager and ensure we've received the initial enumeration
    // before continuing.
    base::RunLoop run_loop;
    auto* chooser_context = HidChooserContextFactory::GetForProfile(profile());
    chooser_context->SetHidManagerForTesting(
        std::move(hid_manager),
        base::BindLambdaForTesting(
            [&run_loop](std::vector<device::mojom::HidDeviceInfoPtr> devices) {
              run_loop.Quit();
            }));
    run_loop.Run();
  }

  void SetUpPolicy(const Extension* extension) {
    g_browser_process->local_state()->Set(
        prefs::kManagedWebHidAllowDevicesForUrls,
        base::test::ParseJson(base::StringPrintf(
            kPolicySetting, extension->url().spec().c_str())));
  }

  void SetUpTestDir(extensions::TestExtensionDir& test_dir,
                    const std::string& background_js) {
    test_dir.WriteManifest(base::StringPrintf(
        R"({
          "name": "Test Extension",
          "version": "0.1",
          "key": "%s",
          "manifest_version": 3,
          "background": {
            "service_worker": "background.js"
          }
        })",
        kTestExtensionKey));
    test_dir.WriteFile(FILE_PATH_LITERAL("background.js"), background_js);
  }

  const Extension* LoadExtensionAndRunTest(const std::string& background_js) {
    extensions::TestExtensionDir test_dir;
    SetUpTestDir(test_dir, background_js);

    // Launch the test app.
    ExtensionTestMessageListener ready_listener("ready",
                                                ReplyBehavior::kWillReply);
    extensions::ResultCatcher result_catcher;
    const Extension* extension = LoadExtension(test_dir.UnpackedPath());
    CHECK(extension);
    CHECK_EQ(extension->id(), kTestExtensionId);

    // TODO(crbug.com/40847683): Grant permission using requestDevice().
    // Run the test.
    SetUpPolicy(extension);
    EXPECT_TRUE(ready_listener.WaitUntilSatisfied());
    ready_listener.Reply("ok");
    EXPECT_TRUE(result_catcher.GetNextResult()) << result_catcher.message();

    return extension;
  }

  device::FakeHidManager* hid_manager() { return &hid_manager_; }

  void SimulateClickOnSystemTrayIconButton(Browser* browser,
                                           const Extension* extension) {
#if BUILDFLAG(IS_CHROMEOS)
    auto* hid_pinned_notification = static_cast<HidPinnedNotification*>(
        g_browser_process->hid_system_tray_icon());

    auto* device_pinned_notification_renderer =
        static_cast<DevicePinnedNotificationRenderer*>(
            hid_pinned_notification->GetIconRendererForTesting());

    auto expected_pinned_notification_id =
        device_pinned_notification_renderer->GetNotificationId(
            browser->GetProfile());
    auto maybe_indicator_notification =
        display_service_for_system_notification_->GetNotification(
            expected_pinned_notification_id);
    ASSERT_TRUE(maybe_indicator_notification);
    EXPECT_TRUE(maybe_indicator_notification->pinned());
    display_service_for_system_notification_->SimulateClick(
        NotificationHandler::Type::TRANSIENT, expected_pinned_notification_id,
        /*action_index=*/0, /*reply=*/std::nullopt);
    auto* web_contents = browser->tab_strip_model()->GetActiveWebContents();
    EXPECT_EQ(web_contents->GetURL(), "chrome://settings/content/hidDevices");
#else
    // On non-ChromeOS platforms, as they use status icon and there isn't good
    // test infra to simulate click on the status icon button, so simulate the
    // click event by invoking ExecuteCommand of HidConnectionTracker directly.
    auto* hid_status_icon =
        static_cast<HidStatusIcon*>(g_browser_process->hid_system_tray_icon());

    auto* status_icon_renderer = static_cast<DeviceStatusIconRenderer*>(
        hid_status_icon->GetIconRendererForTesting());

    status_icon_renderer->ExecuteCommandForTesting(
        IDC_DEVICE_SYSTEM_TRAY_ICON_FIRST, 0);
    EXPECT_EQ(browser->tab_strip_model()->GetActiveWebContents()->GetURL(),
              "https://support.google.com/chrome?p=webhid");

    status_icon_renderer->ExecuteCommandForTesting(
        IDC_DEVICE_SYSTEM_TRAY_ICON_FIRST + 1, 0);
    EXPECT_EQ(browser->tab_strip_model()->GetActiveWebContents()->GetURL(),
              "chrome://settings/content/hidDevices");

    status_icon_renderer->ExecuteCommandForTesting(
        IDC_DEVICE_SYSTEM_TRAY_ICON_FIRST + 2, 0);
    EXPECT_EQ(
        browser->tab_strip_model()->GetActiveWebContents()->GetURL(),
        "chrome://settings/content/siteDetails?site=chrome-extension%3A%2F%2F" +
            extension->id());
#endif
  }

 private:
#if BUILDFLAG(IS_CHROMEOS)
  ash::RegularLoggedInBrowserTestMixin logged_in_mixin_{&mixin_host_,
                                                        kManagedUserAccountId};
  std::unique_ptr<NotificationDisplayServiceTester>
      display_service_for_system_notification_;
#endif  // BUILDFLAG(IS_CHROMEOS)

  device::FakeHidManager hid_manager_;
};

IN_PROC_BROWSER_TEST_F(WebHidExtensionBrowserTest, GetDevices) {
  extensions::TestExtensionDir test_dir;

  auto device = CreateTestDeviceWithInputAndOutputReports();
  hid_manager()->AddDevice(std::move(device));

  constexpr char kBackgroundJs[] = R"(
    chrome.test.sendMessage("ready", async () => {
      try {
        const devices = await navigator.hid.getDevices();
        chrome.test.assertEq(1, devices.length);
        chrome.test.notifyPass();
      } catch (e) {
        chrome.test.fail(e.name + ':' + e.message);
      }
    });
  )";

  LoadExtensionAndRunTest(kBackgroundJs);
}

IN_PROC_BROWSER_TEST_F(WebHidExtensionBrowserTest, RequestDevice) {
  extensions::TestExtensionDir test_dir;

  constexpr char kBackgroundJs[] = R"(
    chrome.test.sendMessage("ready", async () => {
      try {
        chrome.test.assertEq(navigator.hid.requestDevice, undefined);
        chrome.test.notifyPass();
      } catch (e) {
        chrome.test.fail(e.name + ':' + e.message);
      }
    });
  )";

  LoadExtensionAndRunTest(kBackgroundJs);
}

// Test the scenario of waking up the service worker upon device events and
// the service worker being kept alive with active device session.
IN_PROC_BROWSER_TEST_F(WebHidExtensionBrowserTest,
                       DeviceConnectAndOpenDeviceWhenServiceWorkerStopped) {
  content::ServiceWorkerContext* context = browser()
                                               ->GetProfile()
                                               ->GetDefaultStoragePartition()
                                               ->GetServiceWorkerContext();
  // Set up an observer for service worker events.
  TestServiceWorkerContextObserver sw_observer(context, kTestExtensionId);

  TestExtensionDir test_dir;
  constexpr char kBackgroundJs[] = R"(
    navigator.hid.onconnect = async (e) => {
      chrome.test.sendMessage("connect", async () => {
        try {
          let device = e.device;
          // Bounce device a few times to make sure nothing unexpected
          // happens.
          await device.open();
          await device.close();
          await device.open();
          await device.close();
          await device.open();
          chrome.test.notifyPass();
        } catch (e) {
          chrome.test.fail(e.name + ':' + e.message);
        }
      });
    }

    navigator.hid.ondisconnect = async (e) => {
      chrome.test.sendMessage("disconnect", async () => {
        try {
          chrome.test.notifyPass();
        } catch (e) {
          chrome.test.fail(e.name + ':' + e.message);
        }
      });
    }
  )";
  SetUpTestDir(test_dir, kBackgroundJs);

  // Launch the test app.
  ExtensionTestMessageListener connect_listener("connect",
                                                ReplyBehavior::kWillReply);
  extensions::ResultCatcher result_catcher;
  const Extension* extension = LoadExtension(test_dir.UnpackedPath());
  // TODO(crbug.com/40847683): Grant permission using requestDevice().
  // Run the test.
  SetUpPolicy(extension);
  ASSERT_TRUE(extension);
  ASSERT_EQ(extension->id(), kTestExtensionId);
  sw_observer.WaitForWorkerStart();
  sw_observer.WaitForWorkerActivated();

  // The device event is handled right after the service worker is activated.
  int64_t service_worker_version_id = sw_observer.GetServiceWorkerVersionId();
  base::SimpleTestTickClock tick_clock;
  auto device = CreateTestDeviceWithInputAndOutputReports();
  hid_manager()->AddDevice(device.Clone());
  EXPECT_TRUE(connect_listener.WaitUntilSatisfied());
  connect_listener.Reply("ok");
  EXPECT_TRUE(result_catcher.GetNextResult()) << result_catcher.message();
  // Advance clock and the service worker is still alive due to active device
  // session.
  content::AdvanceClockAfterRequestTimeout(context, service_worker_version_id,
                                           &tick_clock);
  EXPECT_TRUE(content::TriggerTimeoutAndCheckRunningState(
      context, service_worker_version_id));
  // Since we have active HID device session at this point, click the HID system
  // tray icon and check right links are opened by the browser.
  SimulateClickOnSystemTrayIconButton(browser(), extension);

  // Remove device will close the device session, and worker will stop running
  // when it times out.
  ExtensionTestMessageListener disconnect_listener("disconnect",
                                                   ReplyBehavior::kWillReply);
  hid_manager()->RemoveDevice(device->guid);
  EXPECT_TRUE(disconnect_listener.WaitUntilSatisfied());
  disconnect_listener.Reply("ok");
  EXPECT_TRUE(result_catcher.GetNextResult()) << result_catcher.message();
  // Advance clock and check that the receiver service worker stopped.
  content::AdvanceClockAfterRequestTimeout(context, service_worker_version_id,
                                           &tick_clock);
  EXPECT_FALSE(content::TriggerTimeoutAndCheckRunningState(
      context, service_worker_version_id));
  sw_observer.WaitForWorkerStop();

  // Another device event wakes up the inactive worker.
  connect_listener.Reset();
  hid_manager()->AddDevice(device.Clone());
  EXPECT_TRUE(connect_listener.WaitUntilSatisfied());
  connect_listener.Reply("ok");
  EXPECT_TRUE(result_catcher.GetNextResult()) << result_catcher.message();
  // Advance clock and the service worker is still alive due to active device
  // session.
  content::AdvanceClockAfterRequestTimeout(context, service_worker_version_id,
                                           &tick_clock);
  EXPECT_TRUE(content::TriggerTimeoutAndCheckRunningState(
      context, service_worker_version_id));
  // Since we have active HID device session at this point, click the HID system
  // tray icon and check right links are opened by the browser.
  SimulateClickOnSystemTrayIconButton(browser(), extension);
}

IN_PROC_BROWSER_TEST_F(WebHidExtensionBrowserTest,
                       EventListenerAddedAfterServiceWorkerIsActivated) {
  const char kWarningMessage[] =
      "Event handler of '%s' event must be added on the initial evaluation "
      "of worker script. More info: "
      "https://developer.chrome.com/docs/extensions/mv3/service_workers/"
      "events/";

  content::ServiceWorkerContext* context = browser()
                                               ->GetProfile()
                                               ->GetDefaultStoragePartition()
                                               ->GetServiceWorkerContext();
  // Set up an observer for service worker events.
  TestServiceWorkerContextObserver sw_observer(context, kTestExtensionId);
  // Set up an observer for console messages reported by service worker
  TestServiceWorkerConsoleObserver console_observer(browser()
                                                        ->tab_strip_model()
                                                        ->GetActiveWebContents()
                                                        ->GetBrowserContext());
  TestExtensionDir test_dir;
  constexpr char kBackgroundJs[] = R"(
      chrome.test.sendMessage("ready", function() {
        navigator.hid.addEventListener("connect", () => {});
      });
    )";
  SetUpTestDir(test_dir, kBackgroundJs);

  // Launch the test app.
  extensions::ResultCatcher result_catcher;
  const Extension* extension = LoadExtension(test_dir.UnpackedPath());
  // TODO(crbug.com/40847683): Grant permission using requestDevice().
  // Run the test.
  SetUpPolicy(extension);
  ASSERT_TRUE(extension);
  ASSERT_EQ(extension->id(), kTestExtensionId);
  sw_observer.WaitForWorkerStart();
  sw_observer.WaitForWorkerActivated();

  auto device = CreateTestDeviceWithInputAndOutputReports();
  hid_manager()->AddDevice(device.Clone());

  // Warning message will be displayed when event listener is nested inside a
  // function
  console_observer.WaitForMessages();
  EXPECT_EQ(console_observer.messages().size(), 1u);
  EXPECT_EQ(console_observer.messages().begin()->message_level,
            blink::mojom::ConsoleMessageLevel::kWarning);
  EXPECT_EQ(console_observer.messages().begin()->message,
            base::UTF8ToUTF16(base::StringPrintf(kWarningMessage, "connect")));
}
#endif  // BUILDFLAG(ENABLE_EXTENSIONS)
