// 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 "third_party/blink/renderer/modules/mediastream/capture_controller.h"

#include <algorithm>
#include <cmath>
#include <optional>

#include "base/numerics/safe_conversions.h"
#include "base/types/expected.h"
#include "base/unguessable_token.h"
#include "build/build_config.h"
#include "third_party/blink/public/common/page/page_zoom.h"
#include "third_party/blink/public/platform/browser_interface_broker_proxy.h"
#include "third_party/blink/renderer/bindings/core/v8/idl_types.h"
#include "third_party/blink/renderer/bindings/core/v8/script_promise.h"
#include "third_party/blink/renderer/bindings/modules/v8/v8_captured_wheel_action.h"
#include "third_party/blink/renderer/bindings/modules/v8/v8_media_stream_track_state.h"
#include "third_party/blink/renderer/core/dom/dom_exception.h"
#include "third_party/blink/renderer/core/dom/events/event.h"
#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
#include "third_party/blink/renderer/core/event_type_names.h"
#include "third_party/blink/renderer/core/events/wheel_event.h"
#include "third_party/blink/renderer/core/execution_context/execution_context.h"
#include "third_party/blink/renderer/core/geometry/dom_rect.h"
#include "third_party/blink/renderer/core/html/html_element.h"
#include "third_party/blink/renderer/modules/mediastream/media_stream_track.h"
#include "third_party/blink/renderer/modules/mediastream/media_stream_video_track.h"
#include "third_party/blink/renderer/modules/mediastream/user_media_client.h"
#include "third_party/blink/renderer/platform/bindings/exception_code.h"
#include "third_party/blink/renderer/platform/bindings/exception_state.h"
#include "third_party/blink/renderer/platform/heap/garbage_collected.h"
#include "third_party/blink/renderer/platform/heap/persistent.h"
#include "third_party/blink/renderer/platform/mediastream/media_stream_component.h"
#include "third_party/blink/renderer/platform/mediastream/media_stream_source.h"
#include "third_party/blink/renderer/platform/wtf/functional.h"
#include "third_party/blink/renderer/platform/wtf/vector.h"

using ::blink::mojom::blink::CapturedSurfaceControlResult;

namespace blink {

namespace {

using SurfaceType = media::mojom::DisplayCaptureSurfaceType;

bool IsCaptureType(const MediaStreamTrack* track,
                   const std::vector<SurfaceType>& types) {
  DCHECK(track);

  const MediaStreamVideoTrack* video_track =
      MediaStreamVideoTrack::From(track->Component());
  if (!video_track) {
    return false;
  }

  MediaStreamTrackPlatform::Settings settings;
  video_track->GetSettings(settings);
  const std::optional<SurfaceType> display_surface = settings.display_surface;
  return std::ranges::any_of(
      types, [display_surface](SurfaceType t) { return t == display_surface; });
}

#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
bool ShouldFocusCapturedSurface(V8CaptureStartFocusBehavior focus_behavior) {
  switch (focus_behavior.AsEnum()) {
    case V8CaptureStartFocusBehavior::Enum::kFocusCapturedSurface:
      return true;
    case V8CaptureStartFocusBehavior::Enum::kFocusCapturingApplication:
    case V8CaptureStartFocusBehavior::Enum::kNoFocusChange:
      return false;
  }
  NOTREACHED();
}

std::optional<int> GetInitialZoomLevel(MediaStreamTrack* video_track) {
  const MediaStreamVideoSource* native_source =
      MediaStreamVideoSource::GetVideoSource(
          video_track->Component()->Source());
  if (!native_source) {
    return std::nullopt;
  }

  const media::mojom::DisplayMediaInformationPtr& display_media_info =
      native_source->device().display_media_info;
  if (!display_media_info ||
      display_media_info->display_surface != SurfaceType::BROWSER) {
    return std::nullopt;
  }

  return display_media_info->initial_zoom_level;
}

std::optional<base::UnguessableToken> GetCaptureSessionId(
    MediaStreamTrack* track) {
  if (!track) {
    return std::nullopt;
  }
  MediaStreamComponent* component = track->Component();
  if (!component) {
    return std::nullopt;
  }
  MediaStreamSource* source = component->Source();
  if (!source) {
    return std::nullopt;
  }
  WebPlatformMediaStreamSource* platform_source = source->GetPlatformSource();
  if (!platform_source) {
    return std::nullopt;
  }
  return platform_source->device().serializable_session_id();
}

DOMException* CscResultToDOMException(CapturedSurfaceControlResult result) {
  switch (result) {
    case CapturedSurfaceControlResult::kSuccess:
      return nullptr;
    case CapturedSurfaceControlResult::kUnknownError:
      return MakeGarbageCollected<DOMException>(DOMExceptionCode::kUnknownError,
                                                "Unknown error.");
    case CapturedSurfaceControlResult::kNoPermissionError:
      return MakeGarbageCollected<DOMException>(
          DOMExceptionCode::kNotAllowedError, "No permission.");
    case CapturedSurfaceControlResult::kCapturerNotFoundError:
      return MakeGarbageCollected<DOMException>(
          DOMExceptionCode::kNotFoundError, "Capturer not found.");
    case CapturedSurfaceControlResult::kCapturedSurfaceNotFoundError:
      return MakeGarbageCollected<DOMException>(
          DOMExceptionCode::kNotFoundError, "Captured surface not found.");
    case CapturedSurfaceControlResult::kDisallowedForSelfCaptureError:
      return MakeGarbageCollected<DOMException>(
          DOMExceptionCode::kInvalidStateError,
          "API not supported for self-capture.");
    case CapturedSurfaceControlResult::kCapturerNotFocusedError:
      return MakeGarbageCollected<DOMException>(
          DOMExceptionCode::kInvalidStateError,
          "Capturing application not focused.");
    case CapturedSurfaceControlResult::kMinZoomLevel:
      return MakeGarbageCollected<DOMException>(
          DOMExceptionCode::kInvalidStateError,
          "Cannot decrease zoom level beyond the minimum value.");
    case CapturedSurfaceControlResult::kMaxZoomLevel:
      return MakeGarbageCollected<DOMException>(
          DOMExceptionCode::kInvalidStateError,
          "Cannot increase zoom level beyond the maximum value.");
  }
  NOTREACHED();
}

void OnCapturedSurfaceControlResult(
    ScriptPromiseResolver<IDLUndefined>* resolver,
    CapturedSurfaceControlResult result) {
  if (auto* exception = CscResultToDOMException(result)) {
    resolver->Reject(exception);
  } else {
    resolver->Resolve();
  }
}
#endif  // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)

}  // namespace

#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
class CaptureController::WheelEventListener : public NativeEventListener {
 public:
  WheelEventListener(ScriptState* script_state, CaptureController* controller)
      : script_state_(script_state), controller_(controller) {}

  void ListenTo(HTMLElement* element) {
    if (element_) {
      element_->removeEventListener(event_type_names::kWheel, this,
                                    /*use_capture=*/false);
    }
    element_ = element;
    if (element_) {
      element_->addEventListener(event_type_names::kWheel, this);
    }
  }

  void StopListening() { ListenTo(nullptr); }

  // NativeEventListener
  void Invoke(ExecutionContext* context, Event* event) override {
    CHECK(element_);
    CHECK(controller_);
    WheelEvent* wheel_event = DynamicTo<WheelEvent>(event);
    if (!wheel_event || !wheel_event->isTrusted()) {
      return;
    }

    DOMRect* element_rect = element_->GetBoundingClientRect();
    double relative_x =
        static_cast<double>(wheel_event->offsetX()) / element_rect->width();
    double relative_y =
        static_cast<double>(wheel_event->offsetY()) / element_rect->height();

    if (relative_x < 0.0 || relative_x >= 1.0 || relative_y < 0.0 ||
        relative_y >= 1.0) {
      return;
    }

    controller_->SendWheel(relative_x, relative_y, -wheel_event->deltaX(),
                           -wheel_event->deltaY());
  }

  void Trace(Visitor* visitor) const override {
    NativeEventListener::Trace(visitor);
    visitor->Trace(script_state_);
    visitor->Trace(controller_);
    visitor->Trace(element_);
  }

 private:
  Member<ScriptState> script_state_;
  Member<CaptureController> controller_;
  Member<HTMLElement> element_;
};
#endif  // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)

CaptureController::ValidationResult::ValidationResult(DOMExceptionCode code,
                                                      String message)
    : code(code), message(message) {}

Vector<int> CaptureController::getSupportedZoomLevelsForTabs() {
  const wtf_size_t kSize =
      static_cast<wtf_size_t>(kPresetBrowserZoomFactors.size());
  // If later developers modify `kPresetBrowserZoomFactors` to include many more
  // entries than original intended, they should consider modifying this
  // Web-exposed API to either:
  // * Allow the Web application provide the max levels it wishes to receive.
  // * Do some UA-determined trimming.
  CHECK_LE(kSize, 100u) << "Excessive zoom levels.";
  CHECK_EQ(kMinimumBrowserZoomFactor, kPresetBrowserZoomFactors.front());
  CHECK_EQ(kMaximumBrowserZoomFactor, kPresetBrowserZoomFactors.back());

  Vector<int> result(kSize);
  if (kSize == 0) {
    return result;
  }

  result[0] = base::ClampCeil(100 * kPresetBrowserZoomFactors[0]);
  for (wtf_size_t i = 1; i < kSize; ++i) {
    result[i] = base::ClampFloor(100 * kPresetBrowserZoomFactors[i]);
    CHECK_LT(result[i - 1], result[i]) << "Must be monotonically increasing.";
  }

  return result;
}

CaptureController* CaptureController::Create(ExecutionContext* context) {
  return MakeGarbageCollected<CaptureController>(context);
}

CaptureController::CaptureController(ExecutionContext* context)
    : ExecutionContextClient(context)
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
      ,
      media_stream_dispatcher_host_(context)
#endif
{
}

void CaptureController::setFocusBehavior(
    V8CaptureStartFocusBehavior focus_behavior,
    ExceptionState& exception_state) {
  DCHECK(IsMainThread());

  if (!GetExecutionContext()) {
    return;
  }

  if (focus_decision_finalized_) {
    exception_state.ThrowDOMException(
        DOMExceptionCode::kInvalidStateError,
        "The window of opportunity for focus-decision is closed.");
    return;
  }

  if (!video_track_) {
    focus_behavior_ = focus_behavior;
    return;
  }

  if (video_track_->readyState() != V8MediaStreamTrackState::Enum::kLive) {
    exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
                                      "The video track must be live.");
    return;
  }

  if (!IsCaptureType(video_track_,
                     {SurfaceType::BROWSER, SurfaceType::WINDOW})) {
    exception_state.ThrowDOMException(
        DOMExceptionCode::kInvalidStateError,
        "The captured display surface must be either a tab or a window.");
    return;
  }

  focus_behavior_ = focus_behavior;
  FinalizeFocusDecision();
}

ScriptPromise<IDLUndefined> CaptureController::forwardWheel(
    ScriptState* script_state,
    HTMLElement* element) {
  DCHECK(IsMainThread());
  auto* resolver =
      MakeGarbageCollected<ScriptPromiseResolver<IDLUndefined>>(script_state);
  const auto promise = resolver->Promise();
#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
  resolver->RejectWithDOMException(DOMExceptionCode::kNotSupportedError,
                                   "Unsupported.");
  return promise;
#else
  if (!element) {
    if (wheel_listener_) {
      wheel_listener_->StopListening();
    }
    resolver->Resolve();
    return promise;
  }

  std::optional<base::UnguessableToken> session_id =
      GetCaptureSessionId(video_track_);
  if (!session_id.has_value()) {
    resolver->RejectWithDOMException(DOMExceptionCode::kInvalidStateError,
                                     "Invalid capture.");
    return promise;
  }

  ValidationResult validation_result = ValidateCapturedSurfaceControlCall();
  if (validation_result.code != DOMExceptionCode::kNoError) {
    resolver->RejectWithDOMException(validation_result.code,
                                     validation_result.message);
    return promise;
  }

  GetMediaStreamDispatcherHost()->RequestCapturedSurfaceControlPermission(
      *session_id, BindOnce(&CaptureController::OnForwardWheelPermissionResult,
                            WrapWeakPersistent(this), WrapPersistent(resolver),
                            WrapWeakPersistent(element)));
  return promise;
#endif  // BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
}

Vector<int> CaptureController::getSupportedZoomLevels(
    ExceptionState& exception_state) {
  if (!video_track_ || video_track_->Ended()) {
    exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
                                      "Not actively capturing.");
    return Vector<int>();
  }

  if (!IsCaptureType(video_track_, {SurfaceType::BROWSER})) {
    exception_state.ThrowDOMException(DOMExceptionCode::kNotSupportedError,
                                      "Not a supported surface type.");
    return Vector<int>();
  }

  return getSupportedZoomLevelsForTabs();
}

std::optional<int> CaptureController::zoomLevel() const {
  return zoom_level_;
}

ScriptPromise<IDLUndefined> CaptureController::increaseZoomLevel(
    ScriptState* script_state) {
  return UpdateZoomLevel(script_state,
                         mojom::blink::ZoomLevelAction::kIncrease);
}

ScriptPromise<IDLUndefined> CaptureController::decreaseZoomLevel(
    ScriptState* script_state) {
  return UpdateZoomLevel(script_state,
                         mojom::blink::ZoomLevelAction::kDecrease);
}

ScriptPromise<IDLUndefined> CaptureController::resetZoomLevel(
    ScriptState* script_state) {
  return UpdateZoomLevel(script_state, mojom::blink::ZoomLevelAction::kReset);
}

void CaptureController::SetVideoTrack(MediaStreamTrack* video_track,
                                      std::string descriptor_id) {
  DCHECK(IsMainThread());
  DCHECK(video_track);
  DCHECK(!video_track_);
  DCHECK(!descriptor_id.empty());
  DCHECK(descriptor_id_.empty());

  video_track_ = video_track;
  // The CaptureController-Source mapping cannot change after having been set
  // up, and the observer remains until either object is garbage collected. No
  // explicit deregistration of the observer is necessary.
  video_track_->Component()->AddSourceObserver(this);
  descriptor_id_ = std::move(descriptor_id);
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
  zoom_level_ = GetInitialZoomLevel(video_track_);
#endif
}

const AtomicString& CaptureController::InterfaceName() const {
  return event_target_names::kCaptureController;
}

ExecutionContext* CaptureController::GetExecutionContext() const {
  return ExecutionContextClient::GetExecutionContext();
}

void CaptureController::FinalizeFocusDecision() {
  DCHECK(IsMainThread());

  if (focus_decision_finalized_) {
    return;
  }

  focus_decision_finalized_ = true;

  if (!video_track_ || !IsCaptureType(video_track_, {SurfaceType::BROWSER,
                                                     SurfaceType::WINDOW})) {
    return;
  }

  UserMediaClient* client = UserMediaClient::From(DomWindow());
  if (!client) {
    return;
  }

  if (!focus_behavior_) {
    return;
  }

#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
  client->FocusCapturedSurface(
      String(descriptor_id_),
      ShouldFocusCapturedSurface(focus_behavior_.value()));
#endif
}

#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
void CaptureController::SourceChangedZoomLevel(int zoom_level) {
  DCHECK(IsMainThread());

  if (!video_track_ || video_track_->Ended() ||
      !IsCaptureType(video_track_, {SurfaceType::BROWSER}) ||
      zoom_level_ == zoom_level) {
    return;
  }

  zoom_level_ = zoom_level;

  DispatchEvent(*Event::Create(event_type_names::kZoomlevelchange));
}

void CaptureController::OnForwardWheelPermissionResult(
    ScriptPromiseResolver<IDLUndefined>* resolver,
    HTMLElement* element,
    CapturedSurfaceControlResult result) {
  DCHECK(IsMainThread());
  DOMException* exception = CscResultToDOMException(result);
  if (exception) {
    resolver->Reject(exception);
    return;
  }

  if (!wheel_listener_) {
    wheel_listener_ = MakeGarbageCollected<WheelEventListener>(
        resolver->GetScriptState(), this);
  }
  wheel_listener_->ListenTo(element);
  resolver->Resolve();
}

mojom::blink::MediaStreamDispatcherHost*
CaptureController::GetMediaStreamDispatcherHost() {
  DCHECK(IsMainThread());
  CHECK(GetExecutionContext());
  if (!media_stream_dispatcher_host_.is_bound()) {
    GetExecutionContext()->GetBrowserInterfaceBroker().GetInterface(
        media_stream_dispatcher_host_.BindNewPipeAndPassReceiver(
            GetExecutionContext()->GetTaskRunner(
                TaskType::kInternalMediaRealTime)));
  }

  return media_stream_dispatcher_host_.get();
}

void CaptureController::SetMediaStreamDispatcherHostForTesting(
    mojo::PendingRemote<mojom::blink::MediaStreamDispatcherHost> host) {
  media_stream_dispatcher_host_.Bind(
      std::move(host),
      GetExecutionContext()->GetTaskRunner(TaskType::kInternalMediaRealTime));
}
#endif

void CaptureController::Trace(Visitor* visitor) const {
  visitor->Trace(video_track_);
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
  visitor->Trace(wheel_listener_);
  visitor->Trace(media_stream_dispatcher_host_);
#endif
  EventTarget::Trace(visitor);
  ExecutionContextClient::Trace(visitor);
}

CaptureController::ValidationResult
CaptureController::ValidateCapturedSurfaceControlCall() const {
  if (!is_bound_) {
    return ValidationResult(DOMExceptionCode::kInvalidStateError,
                            "getDisplayMedia() not called yet.");
  }

  if (!video_track_) {
    return ValidationResult(DOMExceptionCode::kInvalidStateError,
                            "Capture-session not started.");
  }

  if (video_track_->readyState() == V8MediaStreamTrackState::Enum::kEnded) {
    return ValidationResult(DOMExceptionCode::kInvalidStateError,
                            "Video track ended.");
  }

  if (!IsCaptureType(video_track_, {SurfaceType::BROWSER})) {
    return ValidationResult(DOMExceptionCode::kNotSupportedError,
                            "Action only supported for tab-capture.");
  }
  return ValidationResult(DOMExceptionCode::kNoError, "");
}

ScriptPromise<IDLUndefined> CaptureController::UpdateZoomLevel(
    ScriptState* script_state,
    mojom::blink::ZoomLevelAction action) {
  DCHECK(IsMainThread());

  auto* resolver =
      MakeGarbageCollected<ScriptPromiseResolver<IDLUndefined>>(script_state);

  const auto promise = resolver->Promise();
#if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
  resolver->RejectWithDOMException(DOMExceptionCode::kNotSupportedError,
                                   "Unsupported.");
  return promise;
#else
  ValidationResult validation_result = ValidateCapturedSurfaceControlCall();
  if (validation_result.code != DOMExceptionCode::kNoError) {
    resolver->RejectWithDOMException(validation_result.code,
                                     validation_result.message);
    return promise;
  }

  const std::optional<base::UnguessableToken>& session_id =
      GetCaptureSessionId(video_track_);
  if (!session_id.has_value()) {
    resolver->RejectWithDOMException(DOMExceptionCode::kUnknownError,
                                     "Invalid capture");
    return promise;
  }

  GetMediaStreamDispatcherHost()->UpdateZoomLevel(
      session_id.value(), action,
      BindOnce(&OnCapturedSurfaceControlResult, WrapPersistent(resolver)));
  return promise;
#endif  // BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS)
}

#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
void CaptureController::SendWheel(double relative_x,
                                  double relative_y,
                                  int32_t wheel_delta_x,
                                  int32_t wheel_delta_y) {
  const std::optional<base::UnguessableToken>& session_id =
      GetCaptureSessionId(video_track_);
  if (!session_id.has_value()) {
    return;
  }

  GetMediaStreamDispatcherHost()->SendWheel(
      *session_id, blink::mojom::blink::CapturedWheelAction::New(
                       relative_x, relative_y, wheel_delta_x, wheel_delta_y));
}
#endif  // !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)

}  // namespace blink
