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

#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/json/values_util.h"
#include "base/test/test_timeouts.h"
#include "chrome/browser/content_settings/cookie_settings_factory.h"
#include "chrome/browser/file_system_access/chrome_file_system_access_permission_context.h"
#include "chrome/browser/file_system_access/file_system_access_permission_request_manager.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/content_settings/core/browser/cookie_settings.h"
#include "components/content_settings/core/common/content_settings.h"
#include "components/permissions/permission_util.h"
#include "content/public/common/content_switches.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/file_system_chooser_test_helpers.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/shell_dialogs/select_file_dialog.h"
#include "url/gurl.h"

namespace {

#define GET_FILE                                    \
  "const [file] = await self.showOpenFilePicker();" \
  "self.entry = file;"

#define TRY_CATCH_OBSERVE_FILE                         \
  "async function onChange(records, observer) {"       \
  "  numRecords += records.length;"                    \
  "};"                                                 \
  "const observer = new FileSystemObserver(onChange);" \
  "try {"                                              \
  "  await observer.observe(self.entry);"              \
  "} catch (e) {"                                      \
  "  return e.toString();"                             \
  "}"                                                  \
  "return 'success';"

constexpr char kSuccessMessage[] = "success";
constexpr char kSecurityErrorMessage[] =
    "SecurityError: Storage directory access is denied.";

}  // namespace

class TestFileSystemAccessPermissionContext
    : public ChromeFileSystemAccessPermissionContext {
 public:
  explicit TestFileSystemAccessPermissionContext(
      content::BrowserContext* context)
      : ChromeFileSystemAccessPermissionContext(context) {}
  ~TestFileSystemAccessPermissionContext() override = default;

 private:
  base::WeakPtrFactory<TestFileSystemAccessPermissionContext> weak_factory_{
      this};
};

class FileSystemObserverTest : public InProcessBrowserTest {
 public:
  FileSystemObserverTest() = default;
  ~FileSystemObserverTest() override = default;

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

  void SetUpOnMainThread() override {
    ASSERT_TRUE(temp_dir_.CreateUniqueTempDir());
#if BUILDFLAG(IS_WIN)
    // Convert path to long format to avoid mixing long and 8.3 formats in test.
    ASSERT_TRUE(temp_dir_.Set(base::MakeLongFilePath(temp_dir_.Take())));
#elif BUILDFLAG(IS_MAC)
    // Temporary files in Mac are created under /var/, which is a symlink that
    // resolves to /private/var/. Set `temp_dir_` directly to the resolved file
    // path, given that the expected FSEvents event paths are reported as
    // resolved paths.
    base::FilePath resolved_path =
        base::MakeAbsoluteFilePath(temp_dir_.GetPath());
    if (!resolved_path.empty()) {
      temp_dir_.Take();
      ASSERT_TRUE(temp_dir_.Set(resolved_path));
    }
#endif
    ASSERT_TRUE(embedded_test_server()->Start());
    test_url_ = embedded_test_server()->GetURL("/title1.html");

    InProcessBrowserTest::SetUpOnMainThread();
  }

  void TearDown() override {
    ASSERT_TRUE(temp_dir_.Delete());
    ui::SelectFileDialog::SetFactory(nullptr);
    InProcessBrowserTest::TearDown();
  }

  content::WebContents* GetWebContents() {
    return browser()->GetTabStripModel()->GetActiveWebContents();
  }

  base::FilePath CreateFileToBePicked() {
    base::FilePath file_path;
    {
      base::ScopedAllowBlockingForTesting allow_blocking;
      EXPECT_TRUE(
          base::CreateTemporaryFileInDir(temp_dir_.GetPath(), &file_path));
      EXPECT_TRUE(base::WriteFile(file_path, "observe me"));
    }

    ui::SelectFileDialog::SetFactory(
        std::make_unique<content::FakeSelectFileDialogFactory>(
            std::vector<base::FilePath>{file_path}));
    EXPECT_TRUE(NavigateToURL(GetWebContents(), test_url_));
    return file_path;
  }

  base::FilePath CreateDirectoryToBePicked() {
    base::FilePath dir_path;
    {
      base::ScopedAllowBlockingForTesting allow_blocking;
      EXPECT_TRUE(base::CreateTemporaryDirInDir(
          temp_dir_.GetPath(), FILE_PATH_LITERAL("test"), &dir_path));
    }

    ui::SelectFileDialog::SetFactory(
        std::make_unique<content::FakeSelectFileDialogFactory>(
            std::vector<base::FilePath>{dir_path}));
    EXPECT_TRUE(NavigateToURL(GetWebContents(), test_url_));
    return dir_path;
  }

  void SetUpCommandLine(base::CommandLine* command_line) override {
    // Enable experimental web platform features to enable read/write access.
    command_line->AppendSwitch(
        switches::kEnableExperimentalWebPlatformFeatures);
    command_line->AppendSwitchASCII(switches::kEnableBlinkFeatures,
                                    "FileSystemObserver");
  }

  void ConfigureCookieSetting(const GURL& url, ContentSetting setting) {
    CookieSettingsFactory::GetForProfile(browser()->GetProfile())
        ->SetCookieSetting(url, setting);
  }

  bool SupportsReportingModifiedPath() const {
#if BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_CHROMEOS) || BUILDFLAG(IS_WIN) || \
    BUILDFLAG(IS_MAC)
    return true;
#else
    return false;
#endif  // BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_CHROMEOS) || BUILDFLAG(IS_WIN) ||
        // BUILDFLAG(IS_MAC)
  }

  bool SupportsChangeInfo() const { return SupportsReportingModifiedPath(); }

 protected:
  base::ScopedTempDir temp_dir_;
  GURL test_url_;
};

IN_PROC_BROWSER_TEST_F(FileSystemObserverTest, StorageAccessAllowed) {
  CreateFileToBePicked();
  ConfigureCookieSetting(test_url_, CONTENT_SETTING_ALLOW);

  // Start observing the file.
  std::string script =
      // clang-format off
      "(async () => {"
         GET_FILE
         TRY_CATCH_OBSERVE_FILE
      "})()";
  // clang-format on
  EXPECT_EQ(EvalJs(GetWebContents(), script), kSuccessMessage);
}

IN_PROC_BROWSER_TEST_F(FileSystemObserverTest, StorageAccessBlocked) {
  CreateFileToBePicked();
  ConfigureCookieSetting(test_url_, CONTENT_SETTING_ALLOW);

  // Pick a file to observe.
  std::string script =
      // clang-format off
      "(async () => {"
         GET_FILE
      "})()";
  // clang-format on
  EXPECT_TRUE(ExecJs(GetWebContents(), script));

  ConfigureCookieSetting(test_url_, CONTENT_SETTING_BLOCK);
  // Attempt to observe the file. This should fail as the storage access is
  // blocked.
  script =
      // clang-format off
      "(async () => {"
        TRY_CATCH_OBSERVE_FILE
      "})()";
  // clang-format on
  EXPECT_EQ(EvalJs(GetWebContents(), script), kSecurityErrorMessage);
}

IN_PROC_BROWSER_TEST_F(FileSystemObserverTest,
                       StorageAccessChangeFromAllowedToBlocked) {
  CreateFileToBePicked();
  ConfigureCookieSetting(test_url_, CONTENT_SETTING_ALLOW);

  // Start observing the file.
  std::string script =
      // clang-format off
      "(async () => {"
        GET_FILE
        TRY_CATCH_OBSERVE_FILE
      "})()";
  // clang-format on
  EXPECT_EQ(EvalJs(GetWebContents(), script), kSuccessMessage);

  ConfigureCookieSetting(test_url_, CONTENT_SETTING_BLOCK);

  // The cached value will be used. So, the new state will be ignored.
  EXPECT_EQ(EvalJs(GetWebContents(), script), kSuccessMessage);
}

IN_PROC_BROWSER_TEST_F(FileSystemObserverTest,
                       StorageAccessChangeFromBlockedToAllowed) {
  CreateFileToBePicked();
  ConfigureCookieSetting(test_url_, CONTENT_SETTING_ALLOW);

  // Pick a file to observe.
  std::string script =
      // clang-format off
      "(async () => {"
         GET_FILE
      "})()";
  // clang-format on
  EXPECT_TRUE(ExecJs(GetWebContents(), script));

  ConfigureCookieSetting(test_url_, CONTENT_SETTING_BLOCK);
  // Attempt to observe the file. This should fail as the storage access is
  // blocked.
  script =
      // clang-format off
      "(async () => {"
        TRY_CATCH_OBSERVE_FILE
      "})()";
  // clang-format on
  EXPECT_EQ(EvalJs(GetWebContents(), script), kSecurityErrorMessage);

  ConfigureCookieSetting(test_url_, CONTENT_SETTING_ALLOW);

  // The cached value will be used. So, the new state will be ignored.
  EXPECT_EQ(EvalJs(GetWebContents(), script), kSecurityErrorMessage);
}

IN_PROC_BROWSER_TEST_F(FileSystemObserverTest,
                       ErrorsAfterPermissionsAreRevoked) {
  auto file = CreateFileToBePicked();

  auto* browser_profile = browser()->GetProfile();
  TestFileSystemAccessPermissionContext permission_context(browser_profile);
  content::SetFileSystemAccessPermissionContext(browser_profile,
                                                &permission_context);

  std::string setup_script = content::JsReplace(
      R"""((async () => {
        // Constants
        const actionTimeoutMs = $1;

        // Setup observer
        let records = [];
        function onChange(recs) {
          records.push(...recs.map(record => record.type));
        };
        self.observer = new FileSystemObserver(onChange);

        // Observe a file.
        const [file] = await self.showOpenFilePicker();
        await observer.observe(file);

        // Returns a promise that resolves after `actionTimeoutMs` to the list
        // of records observed by the observer.
        self.collectRecords = () => {
          const {promise, resolve} = Promise.withResolvers();
          setTimeout(() => {
            resolve([...records]);
            records = [];
          }, actionTimeoutMs);
          return promise;
        };
      })())""",
      base::Int64ToValue(TestTimeouts::action_timeout().InMilliseconds()));

  std::string get_results_script = "collectRecords()";

  EXPECT_TRUE(ExecJs(GetWebContents(), setup_script));

  {
    base::ScopedAllowBlockingForTesting allow_blocking;

    ASSERT_TRUE(base::WriteFile(file, "content"));
    auto records =
        EvalJs(GetWebContents(), get_results_script).TakeValue().TakeList();
    const std::string expected_change_type =
        SupportsChangeInfo() ? "modified" : "unknown";

    // Expect that we received at least one "modified" event.
    ASSERT_THAT(records, testing::Not(testing::IsEmpty()));
    EXPECT_THAT(records.front().GetString(),
                testing::StrEq(expected_change_type));
  }

  auto origin = url::Origin::Create(test_url_);
  permission_context.RevokeGrants(origin);

  {
    base::ScopedAllowBlockingForTesting allow_blocking;
    auto records =
        EvalJs(GetWebContents(), get_results_script).TakeValue().TakeList();

    // Expect that we received only one "errored" event.
    ASSERT_THAT(records, testing::SizeIs(1));
    EXPECT_THAT(records.front().GetString(), testing::StrEq("errored"));
  }

  {
    base::ScopedAllowBlockingForTesting allow_blocking;
    ASSERT_TRUE(base::WriteFile(file, "content v2"));
    auto records =
        EvalJs(GetWebContents(), get_results_script).TakeValue().TakeList();

    // Expect that no more events are received after it's errored.
    ASSERT_THAT(records, testing::IsEmpty());
  }
}

IN_PROC_BROWSER_TEST_F(FileSystemObserverTest,
                       ErrorsAfterRevokeAllActiveGrants) {
  auto dir = CreateDirectoryToBePicked();

  auto* browser_profile = browser()->GetProfile();
  TestFileSystemAccessPermissionContext permission_context(browser_profile);
  content::SetFileSystemAccessPermissionContext(browser_profile,
                                                &permission_context);

  FileSystemAccessPermissionRequestManager::FromWebContents(GetWebContents())
      ->set_auto_response_for_test(permissions::PermissionAction::GRANTED);

  std::string setup_script = content::JsReplace(
      R"""((async () => {
        // Constants
        const actionTimeoutMs = $1;

        // Setup observer
        let records = [];
        function onChange(recs) {
          records.push(...recs.map(record => record.type));
        };
        self.observer = new FileSystemObserver(onChange);

        // Observe a directory.
        const dir = await self.showDirectoryPicker();
        await observer.observe(dir, { recursive: false });

        // Returns a promise that resolves after `actionTimeoutMs` to the list
        // of records observed by the observer.
        self.collectRecords = () => {
          const {promise, resolve} = Promise.withResolvers();
          setTimeout(() => {
            resolve([...records]);
            records = [];
          }, actionTimeoutMs);
          return promise;
        };
      })())""",
      base::Int64ToValue(TestTimeouts::action_timeout().InMilliseconds()));

  EXPECT_TRUE(ExecJs(GetWebContents(), setup_script));

  base::FilePath file = dir.Append(FILE_PATH_LITERAL("file.txt"));
  std::string get_results_script = "collectRecords()";
  {
    base::ScopedAllowBlockingForTesting allow_blocking;
    ASSERT_TRUE(base::WriteFile(file, "content"));
    auto records =
        EvalJs(GetWebContents(), get_results_script).TakeValue().TakeList();
    const std::string expected_change_type =
        SupportsChangeInfo() ? "appeared" : "unknown";

    // We expect to receive an "appeared" event when the file is created.
    ASSERT_THAT(records, testing::Not(testing::IsEmpty()));
    EXPECT_THAT(records.front().GetString(),
                testing::StrEq(expected_change_type));
  }

  permission_context.RevokeAllActiveGrants();

  {
    base::ScopedAllowBlockingForTesting allow_blocking;
    auto records =
        EvalJs(GetWebContents(), get_results_script).TakeValue().TakeList();

    // Expect that we received an "errored" event due to the active grants being
    // revoked.
    ASSERT_THAT(records, testing::SizeIs(1));
    EXPECT_THAT(records.front().GetString(), testing::StrEq("errored"));
  }

  {
    base::ScopedAllowBlockingForTesting allow_blocking;
    ASSERT_TRUE(base::WriteFile(file, "content v2"));
    auto records =
        EvalJs(GetWebContents(), get_results_script).TakeValue().TakeList();

    // Expect that no more events are received after it's errored.
    ASSERT_THAT(records, testing::IsEmpty());
  }
}
