// 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 "components/headless/command_handler/headless_command_handler.h"

#include <cstdint>
#include <iostream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

#include "base/base64.h"
#include "base/command_line.h"
#include "base/containers/adapters.h"
#include "base/containers/fixed_flat_map.h"
#include "base/containers/span.h"
#include "base/files/file.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/json/json_writer.h"
#include "base/json/string_escape.h"
#include "base/logging.h"
#include "base/no_destructor.h"
#include "base/path_service.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_split.h"
#include "base/strings/string_util.h"
#include "base/task/sequenced_task_runner.h"
#include "base/task/task_traits.h"
#include "base/task/thread_pool.h"
#include "build/build_config.h"
#include "components/headless/command_handler/grit/headless_command_resources.h"
#include "components/headless/command_handler/headless_command_switches.h"
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/web_contents.h"
#include "content/public/browser/web_ui_data_source.h"
#include "content/public/common/content_switches.h"
#include "ui/base/resource/resource_bundle.h"

namespace headless {

namespace {

// Default file name for screenshot. Can be overridden by "--screenshot" switch.
const char kDefaultScreenshotFileName[] = "screenshot.png";
// Default file name for pdf. Can be overridden by "--print-to-pdf" switch.
const char kDefaultPDFFileName[] = "output.pdf";

const char kChromeHeadlessHost[] = "headless";
const char kChromeHeadlessURL[] = "chrome://headless/";

const char kHeadlessCommandHtml[] = "headless_command.html";
const char kHeadlessCommandJs[] = "headless_command.js";

// Specifies the initial window size: --window-size=w,h. Headless Chrome users
// historically use this to specify expected screenshot size. Originally defined
// in //chrome/common/chrome_switches.h which we cannot include from here.
const char kWindowSize[] = "window-size";

HeadlessCommandHandler::DoneCallback& GetGlobalDoneCallback() {
  static base::NoDestructor<HeadlessCommandHandler::DoneCallback> done_callback;
  return *done_callback;
}

void EnsureHeadlessCommandResources() {
  // Check if our resources are already loaded and bail out early. This happens
  // when running Chrome with --headless switch and headless command resources
  // have been merged into the Chrome's resource pack.
  ui::ResourceBundle& bundle = ui::ResourceBundle::GetSharedInstance();
  if (!bundle.GetRawDataResource(IDR_HEADLESS_COMMAND_HTML).empty()) {
    DCHECK(!bundle.GetRawDataResource(IDR_HEADLESS_COMMAND_JS).empty());
    return;
  }

  // Check if we have headless command resource pack next to our binary and load
  // it if so. Note that this code is expected to run early during the browser
  // startup phase when file system access is still allowed.
  base::FilePath resource_dir;
  bool result = base::PathService::Get(base::DIR_ASSETS, &resource_dir);
  DCHECK(result);

  base::FilePath resource_pack =
      resource_dir.Append(FILE_PATH_LITERAL("headless_command_resources.pak"));
  if (base::PathExists(resource_pack)) {
    bundle.AddDataPackFromPath(resource_pack, ui::kScaleFactorNone);
  }
}

void CreateAndAddHeadlessHostDataSource(
    content::BrowserContext* browser_context) {
  EnsureHeadlessCommandResources();

  content::WebUIDataSource* source = content::WebUIDataSource::CreateAndAdd(
      browser_context, kChromeHeadlessHost);
  DCHECK(source);

  source->AddResourcePath(kHeadlessCommandHtml, IDR_HEADLESS_COMMAND_HTML);
  source->AddResourcePath(kHeadlessCommandJs, IDR_HEADLESS_COMMAND_JS);
}

base::DictValue GetColorDictFromHexColor(uint32_t color, bool has_alpha) {
  base::DictValue dict;
  if (has_alpha) {
    dict.Set("r", static_cast<int>((color & 0xff000000) >> 24));
    dict.Set("g", static_cast<int>((color & 0x00ff0000) >> 16));
    dict.Set("b", static_cast<int>((color & 0x0000ff00) >> 8));
    dict.Set("a", static_cast<int>((color & 0x000000ff)));
  } else {
    dict.Set("r", static_cast<int>((color & 0xff0000) >> 16));
    dict.Set("g", static_cast<int>((color & 0x00ff00) >> 8));
    dict.Set("b", static_cast<int>((color & 0x0000ff)));
  }

  return dict;
}

bool ParseWindowSize(const std::string& window_size, int* width, int* height) {
  std::vector<std::string_view> width_and_height = base::SplitStringPiece(
      window_size, ",x", base::KEEP_WHITESPACE, base::SPLIT_WANT_ALL);
  if (width_and_height.size() != 2 ||
      !base::StringToInt(width_and_height[0], width) ||
      !base::StringToInt(width_and_height[1], height)) {
    return false;
  }

  return *width > 0 && *height > 0;
}

bool GetCommandDictAndOutputPaths(base::DictValue* commands,
                                  base::FilePath* pdf_file_path,
                                  base::FilePath* screenshot_file_path) {
  const base::CommandLine* command_line =
      base::CommandLine::ForCurrentProcess();

  // --dump-dom
  if (command_line->HasSwitch(switches::kDumpDom)) {
    commands->Set("dumpDom", true);
  }

  // --print-to-pdf=[output path]
  if (command_line->HasSwitch(switches::kPrintToPDF)) {
    base::FilePath path =
        command_line->GetSwitchValuePath(switches::kPrintToPDF);
    if (path.empty()) {
      path = base::FilePath().AppendASCII(kDefaultPDFFileName);
    }
    *pdf_file_path = path;

    base::DictValue params;
    if (command_line->HasSwitch(switches::kNoPDFHeaderFooter)) {
      params.Set("noHeaderFooter", true);
    }

    if (command_line->HasSwitch(switches::kDisablePDFTagging)) {
      params.Set("disablePDFTagging", true);
    }

    if (command_line->HasSwitch(switches::kGeneratePDFDocumentOutline)) {
      params.Set("generateDocumentOutline", true);
    }

    commands->Set("printToPDF", std::move(params));
  }

  // --screenshot=[output path]
  if (command_line->HasSwitch(switches::kScreenshot)) {
    base::FilePath path =
        command_line->GetSwitchValuePath(switches::kScreenshot);
    if (path.empty()) {
      path = base::FilePath().AppendASCII(kDefaultScreenshotFileName);
    }
    *screenshot_file_path = path;

    base::FilePath::StringType extension =
        base::ToLowerASCII(path.FinalExtension());

    static constexpr auto kImageFileTypes =
        base::MakeFixedFlatMap<base::FilePath::StringViewType, const char*>({
            {FILE_PATH_LITERAL(".jpeg"), "jpeg"},
            {FILE_PATH_LITERAL(".jpg"), "jpeg"},
            {FILE_PATH_LITERAL(".png"), "png"},
            {FILE_PATH_LITERAL(".webp"), "webp"},
        });

    auto it = kImageFileTypes.find(extension);
    if (it == kImageFileTypes.cend()) {
      LOG(ERROR) << "Unsupported screenshot image file type: "
                 << path.FinalExtension();
      return false;
    }

    base::DictValue params;
    params.Set("format", it->second);

    if (command_line->HasSwitch(kWindowSize)) {
      int width, height;
      if (ParseWindowSize(command_line->GetSwitchValueASCII(kWindowSize),
                          &width, &height)) {
        params.Set("width", width);
        params.Set("height", height);
      } else {
        LOG(ERROR) << "Invalid --" << kWindowSize << " specification ignored";
      }
    }

    commands->Set("screenshot", std::move(params));
  }

  // --default-background-color=rrggbb[aa]
  if (command_line->HasSwitch(switches::kDefaultBackgroundColor)) {
    std::string hex_color =
        command_line->GetSwitchValueASCII(switches::kDefaultBackgroundColor);
    uint32_t color;
    if (!(hex_color.length() == 6 || hex_color.length() == 8) ||
        !base::HexStringToUInt(hex_color, &color)) {
      LOG(ERROR)
          << "Expected a hex RGB or RGBA value for --default-background-color="
          << hex_color;
      return false;
    }

    commands->Set("defaultBackgroundColor",
                  GetColorDictFromHexColor(color, hex_color.length() == 8));
  }

  // virtual-time-budget=[ms]
  if (command_line->HasSwitch(switches::kVirtualTimeBudget)) {
    std::string budget_ms_str =
        command_line->GetSwitchValueASCII(switches::kVirtualTimeBudget);
    int budget_ms;
    if (!base::StringToInt(budget_ms_str, &budget_ms)) {
      LOG(ERROR) << "Expected an integer value for --virtual-time-budget="
                 << budget_ms_str;
      return false;
    }

    commands->Set("virtualTimeBudget", budget_ms);
  }

  // timeout=[ms]
  if (command_line->HasSwitch(switches::kTimeout)) {
    std::string timeout_ms_str =
        command_line->GetSwitchValueASCII(switches::kTimeout);
    int timeout_ms;
    if (!base::StringToInt(timeout_ms_str, &timeout_ms)) {
      LOG(ERROR) << "Expected an integer value for --timeout="
                 << timeout_ms_str;
      return false;
    }

    commands->Set("timeout", timeout_ms);
  }

  return true;
}

bool WriteFileTask(base::FilePath file_path, std::string file_data) {
  auto file_span = base::as_byte_span(file_data);
  if (!base::WriteFile(file_path, file_span)) {
    PLOG(ERROR) << "Failed to write file " << file_path;
    return false;
  }

  std::cerr << file_data.size() << " bytes written to file " << file_path
            << std::endl;
  return true;
}

}  // namespace

HeadlessCommandHandler::HeadlessCommandHandler(
    content::WebContents* web_contents,
    GURL target_url,
    DoneCallback done_callback,
    scoped_refptr<base::SequencedTaskRunner> io_task_runner)
    : target_url_(std::move(target_url)),
      done_callback_(std::move(done_callback)),
      io_task_runner_(std::move(io_task_runner)) {
  DCHECK(web_contents);
  DCHECK(io_task_runner_);

  content::WebContentsObserver::Observe(web_contents);

  // Load command execution harness resources and create URL data source
  // for chrome://headless.
  CreateAndAddHeadlessHostDataSource(web_contents->GetBrowserContext());

  browser_devtools_client_.AttachToBrowser();
  devtools_client_.AttachToWebContents(web_contents);
}

HeadlessCommandHandler::~HeadlessCommandHandler() = default;

// static
GURL HeadlessCommandHandler::GetHandlerUrl() {
  const std::string url =
      base::StrCat({kChromeHeadlessURL, kHeadlessCommandHtml});
  return GURL(url);
}

// static
bool HeadlessCommandHandler::HasHeadlessCommandSwitches(
    const base::CommandLine& command_line) {
  static const char* kCommandSwitches[] = {
      switches::kDefaultBackgroundColor,
      switches::kDumpDom,
      switches::kPrintToPDF,
      switches::kNoPDFHeaderFooter,
      switches::kScreenshot,
      switches::kTimeout,
      switches::kVirtualTimeBudget,
  };

  for (const char* command_switch : kCommandSwitches) {
    if (command_line.HasSwitch(command_switch)) {
      return true;
    }
  }

  return false;
}

// static
void HeadlessCommandHandler::ProcessCommands(
    content::WebContents* web_contents,
    GURL target_url,
    DoneCallback done_callback,
    scoped_refptr<base::SequencedTaskRunner> io_task_runner) {
  if (!io_task_runner) {
    io_task_runner = base::ThreadPool::CreateSequencedTaskRunner(
        {base::MayBlock(), base::TaskPriority::USER_BLOCKING,
         base::TaskShutdownBehavior::BLOCK_SHUTDOWN});
  }

  // Headless Command Handler instance will self delete when done.
  new HeadlessCommandHandler(web_contents, std::move(target_url),
                             std::move(done_callback), io_task_runner);
}

void HeadlessCommandHandler::DocumentOnLoadCompletedInPrimaryMainFrame() {
  // Expose DevTools protocol to the target.
  base::DictValue expose_params;
  expose_params.Set("targetId", devtools_client_.GetTargetId());
  browser_devtools_client_.SendCommand(
      "Target.exposeDevToolsProtocol", std::move(expose_params),
      base::BindOnce(&HeadlessCommandHandler::OnDevToolsProtocolExposed,
                     base::Unretained(this)));
}

void HeadlessCommandHandler::OnDevToolsProtocolExposed(base::DictValue) {
  // Set up Inspector domain.
  devtools_client_.AddEventHandler(
      "Inspector.targetCrashed",
      base::BindRepeating(&HeadlessCommandHandler::OnTargetCrashed,
                          base::Unretained(this)));
  devtools_client_.SendCommand("Inspector.enable");

  // Prepare headless commands.
  base::DictValue commands;
  if (!GetCommandDictAndOutputPaths(&commands, &pdf_file_path_,
                                    &screenshot_file_path_) ||
      commands.empty()) {
    PostDone();
    return;
  }

  commands.Set("targetUrl", target_url_.spec());

  std::string json_commands = base::WriteJson(commands).value_or("");
  std::string script = "executeCommands(JSON.parse(" +
                       base::GetQuotedJSONString(json_commands) + "))";

  base::DictValue params;
  params.Set("expression", script);
  params.Set("awaitPromise", true);
  params.Set("returnByValue", true);
  devtools_client_.SendCommand(
      "Runtime.evaluate", std::move(params),
      base::BindOnce(&HeadlessCommandHandler::OnCommandsResult,
                     base::Unretained(this)));
}

void HeadlessCommandHandler::WebContentsDestroyed() {
  LOG(ERROR) << "Unexpected renderer destruction.";
  Done();
}

void HeadlessCommandHandler::OnTargetCrashed(const base::DictValue&) {
  LOG(ERROR) << "Abnormal renderer termination.";
  Done();
}

void HeadlessCommandHandler::OnCommandsResult(base::DictValue result) {
  if (std::string* page_load_error =
          result.FindStringByDottedPath("result.result.value.pageLoadError")) {
    result_ = Result::kPageLoadError;
    LOG(ERROR) << "Page load failed: " << *page_load_error;
  }

  if (result.FindBoolByDottedPath("result.result.value.pageLoadTimedOut")
          .value_or(false)) {
    result_ = Result::kPageLoadTimeout;
    LOG(ERROR) << "Page load timed out.";
  }

  if (std::string* dom_dump =
          result.FindStringByDottedPath("result.result.value.dumpDomResult")) {
    std::cout << *dom_dump << std::endl;
  }

  if (std::string* base64_data = result.FindStringByDottedPath(
          "result.result.value.screenshotResult")) {
    WriteFile(std::move(screenshot_file_path_), std::move(*base64_data));
  }

  if (std::string* base64_data = result.FindStringByDottedPath(
          "result.result.value.printToPdfResult")) {
    WriteFile(std::move(pdf_file_path_), std::move(*base64_data));
  }

  if (!write_file_tasks_in_flight_) {
    PostDone();
  }
}

void HeadlessCommandHandler::WriteFile(base::FilePath file_path,
                                       std::string base64_file_data) {
  std::string file_data;
  CHECK(base::Base64Decode(base64_file_data, &file_data));

  if (io_task_runner_->PostTaskAndReplyWithResult(
          FROM_HERE,
          base::BindOnce(&WriteFileTask, std::move(file_path),
                         std::move(file_data)),
          base::BindOnce(&HeadlessCommandHandler::OnWriteFileDone,
                         base::Unretained(this)))) {
    ++write_file_tasks_in_flight_;
  }
}

void HeadlessCommandHandler::OnWriteFileDone(bool success) {
  DCHECK_GT(write_file_tasks_in_flight_, 0) << write_file_tasks_in_flight_;

  if (!success) {
    result_ = Result::kWriteFileError;
  }

  if (!--write_file_tasks_in_flight_) {
    Done();
  }
}

void HeadlessCommandHandler::PostDone() {
  content::GetUIThreadTaskRunner({})->PostTask(
      FROM_HERE,
      base::BindOnce(&HeadlessCommandHandler::Done, base::Unretained(this)));
}

void HeadlessCommandHandler::Done() {
  devtools_client_.DetachClient();
  browser_devtools_client_.DetachClient();

  Result result = result_;
  DoneCallback done_callback(std::move(done_callback_));
  delete this;
  std::move(done_callback).Run(result);

  if (GetGlobalDoneCallback()) {
    std::move(GetGlobalDoneCallback()).Run(result);
  }
}

// static
void HeadlessCommandHandler::SetDoneCallbackForTesting(
    DoneCallback done_callback) {
  GetGlobalDoneCallback() = std::move(done_callback);
}

}  // namespace headless
