// Copyright (c) 2017 GitHub, Inc.
// Use of this source code is governed by the MIT license that can be
// found in the LICENSE file.

#include "shell/renderer/web_worker_observer.h"

#include <string_view>
#include <utility>

#include "base/no_destructor.h"
#include "base/threading/thread_local.h"
#include "gin/converter.h"
#include "shell/common/api/electron_bindings.h"
#include "shell/common/node_bindings.h"
#include "shell/common/node_includes.h"
#include "shell/common/node_util.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_wasm_response_extensions.h"  // nogncheck
#include "third_party/blink/renderer/core/execution_context/execution_context.h"  // nogncheck

namespace electron {

namespace {

static base::NoDestructor<base::ThreadLocalOwnedPointer<WebWorkerObserver>>
    lazy_tls;

// Returns true if `context` belongs to a worklet that runs on a thread
// pooled by Blink's WorkletThreadHolder, where the worker thread can be
// reused for multiple worklet contexts. For these scopes the
// WebWorkerObserver and its NodeBindings must outlive the v8::Context so
// the next pooled context can reuse them — Node.js cannot be re-initialized
// on the same thread (the allocator shim only loads once). See callers of
// blink::WorkletThreadHolder in third_party/blink for the authoritative
// list.
bool IsPooledWorkletContext(v8::Local<v8::Context> context) {
  auto* ec = blink::ExecutionContext::From(context);
  if (!ec)
    return false;
  return ec->IsAudioWorkletGlobalScope() || ec->IsPaintWorkletGlobalScope() ||
         ec->IsAnimationWorkletGlobalScope() ||
         ec->IsSharedStorageWorkletGlobalScope();
}

}  // namespace

// static
WebWorkerObserver* WebWorkerObserver::GetCurrent() {
  return lazy_tls->Get();
}

// static
WebWorkerObserver* WebWorkerObserver::Create() {
  auto obs = std::make_unique<WebWorkerObserver>();
  auto* obs_raw = obs.get();
  lazy_tls->Set(std::move(obs));
  return obs_raw;
}

WebWorkerObserver::WebWorkerObserver()
    : node_bindings_(
          NodeBindings::Create(NodeBindings::BrowserEnvironment::kWorker)),
      electron_bindings_(
          std::make_unique<ElectronBindings>(node_bindings_->uv_loop())) {}

WebWorkerObserver::~WebWorkerObserver() = default;

void WebWorkerObserver::WorkerScriptReadyForEvaluation(
    v8::Local<v8::Context> worker_context) {
  active_context_count_++;

  if (environments_.empty()) {
    // First context on this thread - do full Node.js initialization.
    InitializeNewEnvironment(worker_context);
  } else {
    // Thread is being reused (AudioWorklet thread pooling). Share the
    // existing Node.js environment with the new context instead of
    // reinitializing, which would break existing contexts on this thread.
    ShareEnvironmentWithContext(worker_context);
  }
}

void WebWorkerObserver::InitializeNewEnvironment(
    v8::Local<v8::Context> worker_context) {
  v8::Context::Scope context_scope(worker_context);
  v8::Isolate* const isolate = v8::Isolate::GetCurrent();
  v8::MicrotasksScope microtasks_scope(
      worker_context, v8::MicrotasksScope::kDoNotRunMicrotasks);

  // Start the embed thread.
  node_bindings_->PrepareEmbedThread();

  // Setup node tracing controller.
  if (!node::tracing::TraceEventHelper::GetAgent()) {
    auto* tracing_agent = new node::tracing::Agent();
    node::tracing::TraceEventHelper::SetAgent(tracing_agent);
  }

  // Setup node environment for each window.
  v8::Maybe<bool> initialized = node::InitializeContext(worker_context);
  CHECK(!initialized.IsNothing() && initialized.FromJust());
  std::shared_ptr<node::Environment> env =
      node_bindings_->CreateEnvironment(isolate, worker_context, nullptr);

  // CreateEnvironment calls SetIsolateUpForNode which unconditionally
  // registers Node's WebAssembly streaming callback. With kNoBrowserGlobals
  // the JS side of that callback is never installed, so it would crash on
  // use; restore Blink's implementation so WebAssembly.compileStreaming
  // keeps working with Blink's fetch.
  blink::WasmResponseExtensions::Initialize(isolate);

  // We do not want to crash Web Workers on unhandled rejections.
  env->options()->unhandled_rejections = "warn-with-error-code";

  // Add Electron extended APIs.
  electron_bindings_->BindTo(env->isolate(), env->process_object());

  // Load everything.
  node_bindings_->LoadEnvironment(env.get());

  // Make uv loop being wrapped by window context.
  node_bindings_->set_uv_env(env.get());

  // Give the node loop a run to make sure everything is ready.
  node_bindings_->StartPolling();

  // Keep the environment alive until we free it in ContextWillDestroy()
  environments_.insert(std::move(env));
}

void WebWorkerObserver::ShareEnvironmentWithContext(
    v8::Local<v8::Context> worker_context) {
  v8::Context::Scope context_scope(worker_context);
  v8::Isolate* const isolate = v8::Isolate::GetCurrent();
  v8::MicrotasksScope microtasks_scope(
      worker_context, v8::MicrotasksScope::kDoNotRunMicrotasks);

  // Get the existing environment from the first context on this thread.
  DCHECK(!environments_.empty());
  node::Environment* env = environments_.begin()->get();

  // Initialize the V8 context for Node.js use.
  v8::Maybe<bool> initialized = node::InitializeContext(worker_context);
  CHECK(!initialized.IsNothing() && initialized.FromJust());

  // Assign the existing Node.js environment to this new context so that
  // node::Environment::GetCurrent(context) returns the shared environment.
  env->AssignToContext(worker_context, env->principal_realm(),
                       node::ContextInfo("electron_worker"));

  // Get process and require from the original context to make Node.js
  // APIs available in the new context.
  v8::Local<v8::Context> original_context = env->context();
  v8::Local<v8::Object> original_global = original_context->Global();
  v8::Local<v8::Object> new_global = worker_context->Global();

  v8::Local<v8::Value> process_value;
  CHECK(original_global
            ->Get(original_context, gin::StringToV8(isolate, "process"))
            .ToLocal(&process_value));

  v8::Local<v8::Value> require_value;
  CHECK(original_global
            ->Get(original_context, gin::StringToV8(isolate, "require"))
            .ToLocal(&require_value));

  // Set up 'global' as an alias for globalThis. Node.js bootstrapping normally
  // does this during LoadEnvironment, but we skip full bootstrap for shared
  // contexts.
  new_global
      ->Set(worker_context, gin::StringToV8(isolate, "global"), new_global)
      .Check();

  new_global
      ->Set(worker_context, gin::StringToV8(isolate, "process"), process_value)
      .Check();
  new_global
      ->Set(worker_context, gin::StringToV8(isolate, "require"), require_value)
      .Check();

  // Copy Buffer from the original context if it exists.
  v8::Local<v8::Value> buffer_value;
  if (original_global->Get(original_context, gin::StringToV8(isolate, "Buffer"))
          .ToLocal(&buffer_value) &&
      !buffer_value->IsUndefined()) {
    new_global
        ->Set(worker_context, gin::StringToV8(isolate, "Buffer"), buffer_value)
        .Check();
  }

  // Ensure the Blink implementations of fetch and friends are available in
  // the new pooled context. Node.js no longer touches these globals when
  // kNoBrowserGlobals is set, but some worklet global scopes do not expose
  // them at all; fall back to the original context's bindings in that case.
  for (const std::string_view key :
       {"fetch", "Response", "FormData", "Request", "Headers", "EventSource"}) {
    // First, check if the new context has a working Blink version.
    v8::MaybeLocal<v8::Value> blink_value =
        new_global->Get(worker_context, gin::StringToV8(isolate, key));
    if (!blink_value.IsEmpty() && !blink_value.ToLocalChecked()->IsUndefined())
      continue;
    // If not, copy from the original context.
    v8::Local<v8::Value> orig_value;
    if (original_global->Get(original_context, gin::StringToV8(isolate, key))
            .ToLocal(&orig_value) &&
        !orig_value->IsUndefined()) {
      new_global->Set(worker_context, gin::StringToV8(isolate, key), orig_value)
          .Check();
    }
  }
}

void WebWorkerObserver::ContextWillDestroy(v8::Local<v8::Context> context) {
  node::Environment* env = node::Environment::GetCurrent(context);
  if (!env)
    return;

  const bool is_pooled_worklet = IsPooledWorkletContext(context);

  active_context_count_--;

  if (active_context_count_ == 0) {
    // Last context on this thread — full cleanup.
    {
      v8::Context::Scope context_scope(env->context());

      // Emit the "exit" event on the process object. We avoid using
      // gin_helper::EmitEvent here because it goes through
      // CallMethodWithArgs, which creates a node::CallbackScope. During
      // worker shutdown (PrepareForShutdownOnWorkerThread), the
      // CallbackScope destructor's InternalCallbackScope::Close() tries to
      // process ticks and microtask checkpoints, which can SEGV because the
      // worker context is being torn down by Blink.
      v8::Isolate* isolate = env->isolate();
      v8::HandleScope handle_scope(isolate);
      v8::Local<v8::Context> ctx = env->context();
      v8::Local<v8::Value> emit_v;
      if (env->process_object()
              ->Get(ctx, gin::StringToV8(isolate, "emit"))
              .ToLocal(&emit_v) &&
          emit_v->IsFunction()) {
        v8::Local<v8::Value> args[] = {gin::StringToV8(isolate, "exit")};
        // Worker/worklet contexts use kScoped microtask policy (set by
        // Blink). V8 DCHECKs that a MicrotasksScope exists around every
        // Call() under that policy. We use kDoNotRunMicrotasks because
        // the context is mid-teardown.
        v8::MicrotasksScope microtasks_scope{
            isolate, ctx->GetMicrotaskQueue(),
            v8::MicrotasksScope::kDoNotRunMicrotasks};
        v8::TryCatch try_catch(isolate);
        emit_v.As<v8::Function>()
            ->Call(ctx, env->process_object(), 1, args)
            .FromMaybe(v8::Local<v8::Value>());
        // We are mid-teardown and about to destroy the worker's
        // node::Environment, so we cannot let an exception thrown by an
        // 'exit' listener propagate back into Blink (it would assert in
        // V8::FromJustIsNothing on the next call into V8). Log it and
        // explicitly reset the TryCatch so the destructor doesn't rethrow.
        if (try_catch.HasCaught()) {
          if (auto message = try_catch.Message(); !message.IsEmpty()) {
            std::string str;
            if (gin::ConvertFromV8(isolate, message->Get(), &str))
              LOG(ERROR) << "Exception thrown from worker 'exit' handler: "
                         << str;
          }
          try_catch.Reset();
        }
      }
    }

    // Prevent UvRunOnce from using the environment after it's destroyed.
    node_bindings_->set_uv_env(nullptr);

    // Stop the embed thread before destroying environments. The embed
    // thread's PollEvents and FreeEnvironment's uv_run both compete for
    // completions on the same libuv event loop; on Windows (IOCP) this
    // race can deadlock. Joining the embed thread first eliminates the
    // contention so FreeEnvironment's uv_run can drain handles cleanly.
    // For pooled worklets the thread is restarted in
    // InitializeNewEnvironment via PrepareEmbedThread + StartPolling.
    node_bindings_->StopPolling();

    // Destroying the node environment will also run the uv loop.
    {
      util::ExplicitMicrotasksScope microtasks_scope(
          context->GetMicrotaskQueue());
      environments_.clear();
    }

    // ElectronBindings is tracking node environments.
    electron_bindings_->EnvironmentDestroyed(env);

    // For non-pooled worker contexts (e.g., dedicated workers) Blink does
    // not reuse the worker thread, so tear down the observer completely.
    //
    // For pooled worklet contexts (AudioWorklet, PaintWorklet,
    // AnimationWorklet, SharedStorageWorklet — see
    // blink::WorkletThreadHolder) the same NodeBindings must be reused
    // for the next context on the thread because Node.js cannot be
    // re-initialized on the same thread. Keep the observer alive and let
    // the next WorkerScriptReadyForEvaluation call
    // InitializeNewEnvironment, which restarts the embed thread via
    // PrepareEmbedThread + StartPolling.
    if (!is_pooled_worklet) {
      lazy_tls->Set(nullptr);  // destroys *this; do not access members below
      return;
    }
  } else {
    // Other contexts still use the shared environment. Just unassign
    // this context from the environment if it's not the primary context
    // (the primary context must stay assigned because env->context()
    // references it, and UvRunOnce enters that context scope).
    if (context != env->context()) {
      env->UnassignFromContext(context);
    }
    // If the destroyed context IS the primary context, we leave the env
    // assigned to it. The env's PrincipalRealm holds a Global<Context>
    // reference that keeps the V8 context alive even though Blink has
    // torn down its side. This is safe because UvRunOnce only needs
    // the V8 context scope, not Blink-side objects.
  }
}

}  // namespace electron
