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

#include "content/browser/renderer_host/model_context_user_data.h"

#include <algorithm>

#include "content/browser/bad_message.h"
#include "content/browser/renderer_host/frame_tree_node.h"
#include "content/browser/renderer_host/page_impl.h"
#include "content/browser/renderer_host/render_frame_host_impl.h"
#include "content/browser/renderer_host/render_frame_host_manager.h"
#include "content/browser/site_instance_impl.h"
#include "content/public/browser/browser_thread.h"
#include "third_party/blink/public/common/features.h"
#include "url/origin.h"

namespace content {

namespace {

bool IsScriptToolExposedToOrigin(
    const url::Origin& tool_owner_origin,
    const std::vector<url::Origin>& exposed_origins,
    const url::Origin& accessing_origin) {
  if (accessing_origin.IsSameOriginWith(tool_owner_origin)) {
    return true;
  }
  return std::ranges::contains(exposed_origins, accessing_origin);
}

bool IsScriptToolOwnerRequestedByOrigin(
    const url::Origin& tool_owner_origin,
    const url::Origin& caller_origin,
    const std::vector<url::Origin>& from_origins) {
  if (caller_origin.IsSameOriginWith(tool_owner_origin)) {
    return true;
  }
  return std::ranges::contains(from_origins, tool_owner_origin);
}

bool IsWebMCPEnabled(RenderFrameHost& rfh) {
  return base::FeatureList::IsEnabled(blink::features::kWebMCP) &&
         rfh.IsFeatureEnabled(network::mojom::PermissionsPolicyFeature::kTools);
}

}  // namespace

DOCUMENT_USER_DATA_KEY_IMPL(ModelContextUserData);

ModelContextUserData::ModelContextUserData(RenderFrameHost* rfh)
    : DocumentUserData<ModelContextUserData>(rfh) {}

// TODO(https://crbug.com/508285989): In the destructor, implement the implicit
// unregistering of all `script_tools_` and invoking `NotifyToolChange()` on any
// relevant documents. Right now, the destructor only supports the cancelling of
// pending tool execution in the calling renderer process, when the tool host's
// document is destroyed.
ModelContextUserData::~ModelContextUserData() {
  auto& page = render_frame_host().GetPage();
  auto* page_data = ModelContextPageUserData::GetForPage(page);
  if (page_data) {
    auto& rfh_impl = static_cast<RenderFrameHostImpl&>(render_frame_host());
    page_data->CancelPendingScriptToolExecutionsForDocument(
        rfh_impl.GetDocumentToken());
  }
}

// static
void ModelContextUserData::Bind(
    RenderFrameHost* rfh,
    mojo::PendingReceiver<blink::mojom::ModelContextHost> receiver) {
  auto* user_data = ModelContextUserData::GetForCurrentDocument(rfh);
  if (user_data) {
    bad_message::ReceivedBadMessage(rfh->GetProcess(),
                                    bad_message::RFHI_WEBMCP_DUPLICATE_BIND);
    return;
  }
  user_data = ModelContextUserData::GetOrCreateForCurrentDocument(rfh);
  user_data->receiver_.Bind(std::move(receiver));
}

void ModelContextUserData::BindModelContext(
    mojo::PendingRemote<blink::mojom::ModelContext> model_context) {
  if (model_context_remote_.is_bound()) {
    bad_message::ReceivedBadMessage(
        render_frame_host().GetProcess(),
        bad_message::RFHI_WEBMCP_DUPLICATE_SET_RECEIVER);
    return;
  }
  model_context_remote_.Bind(std::move(model_context));
}

void ModelContextUserData::RegisterScriptTool(
    blink::mojom::ScriptToolPtr tool,
    RegisterScriptToolCallback callback) {
  if (!IsWebMCPEnabled(render_frame_host())) {
    bad_message::ReceivedBadMessage(render_frame_host().GetProcess(),
                                    bad_message::RFHI_WEBMCP_NOT_ENABLED);
    std::move(callback).Run();
    return;
  }

  // Kill the renderer if it tries to register a duplicate tool name, because
  // the renderer should prevent this.
  auto it = std::find_if(script_tools_.begin(), script_tools_.end(),
                         [&](const blink::mojom::ScriptToolPtr& t) {
                           return t->name == tool->name;
                         });
  if (it != script_tools_.end()) {
    bad_message::ReceivedBadMessage(
        render_frame_host().GetProcess(),
        bad_message::RFHI_WEBMCP_REGISTER_DUPLICATE_TOOL_NAME);
    std::move(callback).Run();
    return;
  }

  for (const auto& origin : tool->exposed_origins) {
    if (!network::IsOriginPotentiallyTrustworthy(origin)) {
      bad_message::ReceivedBadMessage(
          render_frame_host().GetProcess(),
          bad_message::RFHI_WEBMCP_EXPOSED_UNTRUSTWORTHY_ORIGIN);
      std::move(callback).Run();
      return;
    }
  }

  // TOOD(https://crbug.com/509568047): Stop passing in a frame token and origin
  // during tool registration. These values are obvious from context, and the
  // renderer shouldn't need to pass them in and have the browser verify them.
  // Instead, the browser should be setting them for the first time here, with
  // no input from the renderer.
  if (tool->tool_owner_frame_token != render_frame_host().GetFrameToken() ||
      tool->origin != render_frame_host().GetLastCommittedOrigin()) {
    bad_message::ReceivedBadMessage(
        render_frame_host().GetProcess(),
        bad_message::RFHI_WEBMCP_INVALID_TOOL_OWNER);
    std::move(callback).Run();
    return;
  }

  std::vector<url::Origin> exposed_origins = tool->exposed_origins;
  script_tools_.push_back(std::move(tool));
  NotifyToolChange(exposed_origins);
  std::move(callback).Run();
}

void ModelContextUserData::UnregisterScriptTool(const std::string& name) {
  if (!IsWebMCPEnabled(render_frame_host())) {
    bad_message::ReceivedBadMessage(render_frame_host().GetProcess(),
                                    bad_message::RFHI_WEBMCP_NOT_ENABLED);
    return;
  }

  auto it = std::find_if(
      script_tools_.begin(), script_tools_.end(),
      [&](const blink::mojom::ScriptToolPtr& t) { return t->name == name; });
  if (it == script_tools_.end()) {
    bad_message::ReceivedBadMessage(render_frame_host().GetProcess(),
                                    bad_message::RFHI_WEBMCP_UNKNOWN_TOOL_NAME);
    return;
  }

  std::vector<url::Origin> exposed_origins = (*it)->exposed_origins;

  script_tools_.erase(it);

  NotifyToolChange(exposed_origins);
}

void ModelContextUserData::GetScriptTools(
    const std::vector<url::Origin>& from_origins,
    GetScriptToolsCallback callback) {
  if (!IsWebMCPEnabled(render_frame_host())) {
    bad_message::ReceivedBadMessage(render_frame_host().GetProcess(),
                                    bad_message::RFHI_WEBMCP_NOT_ENABLED);
    std::move(callback).Run({});
    return;
  }

  const url::Origin& caller_origin =
      render_frame_host().GetLastCommittedOrigin();

  std::vector<blink::mojom::ScriptToolPtr> all_tools;
  RenderFrameHost* main_frame = render_frame_host().GetMainFrame();
  SiteInstanceGroup* site_instance_group =
      static_cast<SiteInstanceImpl*>(render_frame_host().GetSiteInstance())
          ->group();
  main_frame->ForEachRenderFrameHostWithAction([&](RenderFrameHost* rfh) {
    if (rfh->GetMainFrame() != main_frame) {
      return RenderFrameHost::FrameIterationAction::kSkipChildren;
    }

    if (!rfh->IsFeatureEnabled(
            network::mojom::PermissionsPolicyFeature::kTools)) {
      return RenderFrameHost::FrameIterationAction::kContinue;
    }

    const url::Origin& tool_owner_origin = rfh->GetLastCommittedOrigin();
    if (!IsScriptToolOwnerRequestedByOrigin(tool_owner_origin, caller_origin,
                                            from_origins)) {
      return RenderFrameHost::FrameIterationAction::kContinue;
    }

    auto* data = ModelContextUserData::GetForCurrentDocument(rfh);
    if (!data) {
      return RenderFrameHost::FrameIterationAction::kContinue;
    }

    const auto& local_tools = data->script_tools();
    for (const auto& t : local_tools) {
      if (!IsScriptToolExposedToOrigin(tool_owner_origin, t->exposed_origins,
                                       /*accessing_origin=*/caller_origin)) {
        continue;
      }

      blink::mojom::ScriptToolPtr cloned_tool = t.Clone();
      // Find the frame (it could be local or remote) that the caller can use
      // to reference the `Window` hosting the tool.
      //
      // `token` will never be `nullopt`.
      blink::FrameToken token =
          *static_cast<RenderFrameHostImpl*>(rfh)
               ->frame_tree_node()
               ->render_manager()
               ->GetFrameTokenForSiteInstanceGroup(site_instance_group);

      cloned_tool->tool_owner_frame_token = token;

      // Clear `exposed_origins` to prevent leaking the full list of
      // authorized origins to the calling renderer process.
      cloned_tool->exposed_origins.clear();

      all_tools.push_back(std::move(cloned_tool));
    }
    return RenderFrameHost::FrameIterationAction::kContinue;
  });

  std::ranges::sort(all_tools, [](const blink::mojom::ScriptToolPtr& a,
                                  const blink::mojom::ScriptToolPtr& b) {
    return a->name < b->name;
  });

  std::move(callback).Run(std::move(all_tools));
}

void ModelContextUserData::ExecuteRemoteScriptTool(
    const base::UnguessableToken& invocation_id,
    const blink::FrameToken& tool_owner_frame_token,
    const url::Origin& expected_target_origin,
    const std::string& name,
    const std::string& input_arguments,
    ExecuteRemoteScriptToolCallback callback) {
  if (!IsWebMCPEnabled(render_frame_host())) {
    bad_message::ReceivedBadMessage(render_frame_host().GetProcess(),
                                    bad_message::RFHI_WEBMCP_NOT_ENABLED);
    std::move(callback).Run(std::nullopt, false);
    return;
  }

  RenderFrameHostImpl* target_rfh = nullptr;

  if (tool_owner_frame_token.Is<blink::LocalFrameToken>()) {
    target_rfh = static_cast<RenderFrameHostImpl*>(
        RenderFrameHost::FromFrameToken(GlobalRenderFrameHostToken(
            render_frame_host().GetProcess()->GetDeprecatedID(),
            tool_owner_frame_token.GetAs<blink::LocalFrameToken>())));
  } else {
    target_rfh =
        static_cast<RenderFrameHostImpl*>(RenderFrameHost::FromPlaceholderToken(
            render_frame_host().GetProcess()->GetDeprecatedID(),
            tool_owner_frame_token.GetAs<blink::RemoteFrameToken>()));
  }

  // Don't kill the renderer, since legitimate script can target a document that
  // no longer exists, or simply target the *wrong* document. It can also target
  // a document in another frame tree, but at the moment we do not support
  // cross-frame-tree, same-browsing-context-group tool execution, hence the
  // main-frame check below.
  RenderFrameHost* main_frame = render_frame_host().GetMainFrame();
  if (!target_rfh || main_frame != target_rfh->GetMainFrame()) {
    std::move(callback).Run(std::nullopt, false);
    return;
  }

  // It is not possible to target the execution of tools that live in a document
  // with an opaque origin; therefore, `expected_target_origin` will never be
  // opaque.
  if (expected_target_origin.opaque()) {
    bad_message::ReceivedBadMessage(
        render_frame_host().GetProcess(),
        bad_message::RFHI_WEBMCP_OPAQUE_TARGET_ORIGIN);
    std::move(callback).Run(std::nullopt, false);
    return;
  }

  // Verify that `target_rfh`'s origin matches the origin that the tool invoker
  // expects the tool to run in. This is necessary because it is possible that
  // the `tool_owner_frame_token` points to a `RenderFrameProxyHost` whose frame
  // tree node's `current_frame_host()` has changed since tool registration.
  if (!target_rfh->GetLastCommittedOrigin().IsSameOriginWith(
          expected_target_origin)) {
    std::move(callback).Run(std::nullopt, false);
    return;
  }

  auto* target_data = ModelContextUserData::GetForCurrentDocument(target_rfh);
  // Don't kill the renderer, since legitimate script can target the wrong
  // document, including one that has never touched its `modelContext` accessor,
  // and therefore does not have `target_data`. However, if our API ever moves
  // to opaque "Tool" interface that internally encapsulates a tool host's frame
  // token instead of making JavaScript pass it in via a `Window` argument, then
  // we'd be able to terminate the renderer here, since it would not be possible
  // for a legitimate renderer to supply a frame token targeting a frame host
  // that exists, but does not have `target_data`.
  if (!target_data) {
    std::move(callback).Run(std::nullopt, false);
    return;
  }

  auto& tools = target_data->script_tools();
  auto it = std::find_if(
      tools.begin(), tools.end(),
      [&](const blink::mojom::ScriptToolPtr& t) { return t->name == name; });

  // Don't kill the renderer, since legitimate script can provide the name of a
  // tool that doesn't exist (including one that did exist, but got unregistered
  // by the time the call to invoke it here).
  if (it == tools.end()) {
    std::move(callback).Run(std::nullopt, false);
    return;
  }

  // Don't kill the renderer here, since legitimate script can target a tool in
  // another document that it might have been told about, but technically cannot
  // access.
  if (!IsScriptToolExposedToOrigin(
          /*tool_owner_origin=*/target_rfh->GetLastCommittedOrigin(),
          (*it)->exposed_origins,
          /*accessing_origin=*/render_frame_host().GetLastCommittedOrigin())) {
    std::move(callback).Run(std::nullopt, false);
    return;
  }

  // At this point, it is safe to invoke the tool in the target renderer pointed
  // to by `target_data`.
  ModelContextPageUserData* page_data =
      ModelContextPageUserData::GetOrCreateForPage(target_rfh->GetPage());
  ModelContextPageUserData::PendingScriptToolExecution execution;
  execution.caller_token =
      static_cast<RenderFrameHostImpl&>(render_frame_host()).GetDocumentToken();
  execution.target_token = target_rfh->GetDocumentToken();
  execution.target_document = target_rfh->GetWeakDocumentPtr();
  execution.tool_name = name;
  execution.callback = std::move(callback);
  page_data->AddPendingScriptToolExecution(invocation_id, std::move(execution));

  target_data->model_context_remote_->ExecuteScriptTool(
      invocation_id, name, input_arguments,
      base::BindOnce(
          [](base::WeakPtr<ModelContextPageUserData> page_data,
             base::UnguessableToken invocation_id,
             const std::optional<std::string>& result, bool success) {
            if (page_data) {
              page_data->CompletePendingScriptToolExecution(invocation_id,
                                                            result, success);
            }
          },
          page_data->GetWeakPtr(), invocation_id));
}

void ModelContextUserData::CancelRemoteScriptTool(
    const base::UnguessableToken& invocation_id) {
  if (ModelContextPageUserData* page_data =
          ModelContextPageUserData::GetOrCreateForPage(
              render_frame_host().GetPage())) {
    page_data->CancelPendingScriptToolExecution(invocation_id);
  }
}

void ModelContextUserData::NotifyToolChange(
    const std::vector<url::Origin>& exposed_origins) {
  RenderFrameHost& rfh = render_frame_host();
  url::Origin tool_owner_origin = rfh.GetLastCommittedOrigin();

  RenderFrameHost* main_frame = rfh.GetMainFrame();
  main_frame->ForEachRenderFrameHostWithAction([&](RenderFrameHost* frame) {
    if (frame->GetMainFrame() != main_frame) {
      return RenderFrameHost::FrameIterationAction::kSkipChildren;
    }

    if (!frame->IsFeatureEnabled(
            network::mojom::PermissionsPolicyFeature::kTools)) {
      return RenderFrameHost::FrameIterationAction::kContinue;
    }

    if (IsScriptToolExposedToOrigin(
            tool_owner_origin, exposed_origins,
            /*accessing_origin=*/frame->GetLastCommittedOrigin())) {
      auto* data = ModelContextUserData::GetForCurrentDocument(frame);
      if (!data) {
        return RenderFrameHost::FrameIterationAction::kContinue;
      }

      if (data->model_context_remote_.is_bound()) {
        data->model_context_remote_->NotifyToolChange();
      }
    }
    return RenderFrameHost::FrameIterationAction::kContinue;
  });
}

PAGE_USER_DATA_KEY_IMPL(ModelContextPageUserData);

ModelContextPageUserData::PendingScriptToolExecution::
    PendingScriptToolExecution() = default;
ModelContextPageUserData::PendingScriptToolExecution::
    PendingScriptToolExecution(PendingScriptToolExecution&&) = default;
ModelContextPageUserData::PendingScriptToolExecution&
ModelContextPageUserData::PendingScriptToolExecution::operator=(
    PendingScriptToolExecution&&) = default;
ModelContextPageUserData::PendingScriptToolExecution::
    ~PendingScriptToolExecution() = default;

ModelContextPageUserData::ModelContextPageUserData(Page& page)
    : PageUserData<ModelContextPageUserData>(page) {}

ModelContextPageUserData::~ModelContextPageUserData() = default;

void ModelContextPageUserData::AddPendingScriptToolExecution(
    const base::UnguessableToken& invocation_id,
    PendingScriptToolExecution execution) {
  pending_script_tool_executions_.emplace(invocation_id, std::move(execution));
}

void ModelContextPageUserData::CompletePendingScriptToolExecution(
    const base::UnguessableToken& invocation_id,
    const std::optional<std::string>& result,
    bool success) {
  // We are here because a tool just returned a result, and we should forward
  // the result to the tool's invoker. But it's possible that no pending
  // execution exists for this tool anymore. This can happen if the invoker
  // document is destroyed before the response from the renderer hosting the
  // tool comes back.
  //
  // Conversely, if the document *hosting* a tool gets destroyed before it
  // responds with the result of the tool the associated pending execution is
  // *also* removed from `this`, but the mojo receiver is terminated before this
  // `CompletePendingScriptToolExecution()` callback is ever run, so we'll never
  // end up here.
  auto it = pending_script_tool_executions_.find(invocation_id);
  if (it == pending_script_tool_executions_.end()) {
    return;
  }

  std::move(it->second.callback).Run(result, success);
  pending_script_tool_executions_.erase(it);
}

void ModelContextPageUserData::SendCancelScriptToolToTarget(
    WeakDocumentPtr target_document,
    const base::UnguessableToken& invocation_id) {
  RenderFrameHost* target_rfh = target_document.AsRenderFrameHostIfValid();
  // `target_rfh` and `target_data` are always non-null, because they are only
  // cleared or invalidated during destruction when the tool owner is destroyed,
  // and in that case `CancelPendingScriptToolExecutionsForDocument()` will not
  // invoke this method.
  CHECK(target_rfh);
  auto* target_data = ModelContextUserData::GetForCurrentDocument(target_rfh);
  CHECK(target_data);
  target_data->model_context_remote_->CancelScriptTool(invocation_id);
}

void ModelContextPageUserData::CancelPendingScriptToolExecution(
    const base::UnguessableToken& invocation_id) {
  // We are here because a caller document aborted its execution signal for a
  // tool. However, it is possible that no pending execution exists for this
  // `invocation_id` anymore. This can happen in legitimate, non-compromised
  // renderer races:
  //   1. The tool execution completed and sent its response IPC
  //      (`CompletePendingScriptToolExecution()`) before this cancellation
  //      IPC arrived.
  //   2. The document hosting the tool was destroyed
  //      (`CancelPendingScriptToolExecutionsForDocument()`) before this
  //      cancellation IPC arrived.
  auto it = pending_script_tool_executions_.find(invocation_id);
  if (it == pending_script_tool_executions_.end()) {
    return;
  }
  SendCancelScriptToolToTarget(it->second.target_document, invocation_id);
  std::move(it->second.callback).Run(std::nullopt, false);
  pending_script_tool_executions_.erase(it);
}

void ModelContextPageUserData::CancelPendingScriptToolExecutionsForDocument(
    const blink::DocumentToken& document_token) {
  for (auto it = pending_script_tool_executions_.begin();
       it != pending_script_tool_executions_.end();) {
    const bool is_caller = it->second.caller_token == document_token;
    const bool is_target = it->second.target_token == document_token;
    if (!is_caller && !is_target) {
      ++it;
      continue;
    }

    // If the now-destructing document represented by `document_token` owns the
    // tool, then it's too late to send a cancellation IPC to it. During
    // document destruction, document weak pointers like `target_document` below
    // are still valid, but they point to a `RenderFrameHostImpl` whose user
    // data (thus `ModelContextUserData`) has already been cleared. So it's
    // impossible to grab a remote to the renderer and send the IPC.
    if (!is_target) {
      SendCancelScriptToolToTarget(it->second.target_document, it->first);
    }
    std::move(it->second.callback).Run(std::nullopt, false);
    it = pending_script_tool_executions_.erase(it);
  }
}

base::WeakPtr<ModelContextPageUserData> ModelContextPageUserData::GetWeakPtr() {
  return weak_factory_.GetWeakPtr();
}

}  // namespace content
