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

#include "chrome/browser/ui/views/tabs/dragging/dragging_tabs_session.h"

#include <optional>

#include "base/metrics/histogram_functions.h"
#include "base/types/pass_key.h"
#include "chrome/browser/ui/layout_constants.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "chrome/browser/ui/views/tabs/dragging/drag_session_data.h"
#include "chrome/browser/ui/views/tabs/dragging/tab_drag_context.h"
#include "chrome/browser/ui/views/tabs/tab_slot_view.h"
#include "chrome/browser/ui/views/tabs/tab_strip.h"
#include "components/viz/common/frame_timing_details.h"
#include "ui/compositor/compositor.h"
#include "ui/views/widget/widget.h"

namespace {
constexpr char kDragAmongTabsPresentationTimeHistogram[] =
    "Browser.TabDragging.DragAmongTabsPresentationTime";

int CalculateMouseOffset(const DragSessionData& drag_data_) {
  std::vector<TabSlotView*> tabs_to_source(drag_data_.attached_views());
  TabSlotView* source_view = drag_data_.source_view_drag_data()->attached_view;
  tabs_to_source.erase(
      tabs_to_source.begin() + drag_data_.source_view_index_ + 1,
      tabs_to_source.end());
  const int new_x =
      TabStrip::GetSizeNeededForViews(tabs_to_source) - source_view->width() +
      base::ClampRound(drag_data_.mouse_offset_to_size_ratios.x() *
                       source_view->width());

  return new_x;
}

}  // namespace

DraggingTabsSession::DraggingTabsSession(
    DragSessionData drag_data,
    TabDragContext& attached_context,
    TabDragPositioningDelegate& drag_position_delegate,
    bool initial_move,
    gfx::Point start_point_in_screen)
    : drag_data_(drag_data),
      attached_context_(attached_context),
      drag_position_delegate_(drag_position_delegate),
      mouse_offset_(CalculateMouseOffset(drag_data_)),
      initial_move_(initial_move),
      last_move_attached_context_loc_(
          views::View::ConvertPointFromScreen(&attached_context,
                                              start_point_in_screen)
              .x()),
      last_point_in_screen_(start_point_in_screen) {
  MoveAttachedImpl(start_point_in_screen, true);
}

DraggingTabsSession::~DraggingTabsSession() = default;

void DraggingTabsSession::MoveAttached(gfx::Point point_in_screen) {
  MoveAttachedImpl(point_in_screen, false);
}

void DraggingTabsSession::MoveAttachedImpl(gfx::Point point_in_screen,
                                           bool just_attached) {
  last_point_in_screen_ = point_in_screen;

  const gfx::Point dragged_view_point = GetAttachedDragPoint(point_in_screen);

  std::vector<TabSlotView*> views(drag_data_.tab_drag_data_.size());
  for (size_t i = 0; i < drag_data_.tab_drag_data_.size(); ++i) {
    views[i] = drag_data_.tab_drag_data_[i].attached_view.get();
  }

  bool did_layout = false;

  const gfx::Point point_in_attached_context =
      views::View::ConvertPointFromScreen(base::to_address(attached_context_),
                                          point_in_screen);

  const int to_index =
      drag_position_delegate_->GetInsertionIndexForDraggedBounds(
          GetDraggedViewTabStripBounds(dragged_view_point),
          drag_data_.attached_views(), drag_data_.num_dragging_tabs());

  constexpr int kHorizontalMoveThreshold = 16;  // DIPs.
  const int threshold = base::ClampRound(
      static_cast<double>(
          drag_position_delegate_->GetTabAt(to_index)->bounds().width()) /
      TabStyle::Get()->GetStandardWidth(/*is_split=*/false) *
      kHorizontalMoveThreshold);

  // Update the model, moving the WebContents from one index to another. Do this
  // only if we have moved a minimum distance since the last reorder (to prevent
  // jitter), or if this the first move and the tabs are not consecutive, or if
  // we have just attached to a new tabstrip and need to move to the correct
  // initial position.
  if (just_attached ||
      (abs(point_in_attached_context.x() - last_move_attached_context_loc_) >
       threshold) ||
      (initial_move_ && !AreTabsConsecutive())) {
    TabStripModel* attached_model = attached_context_->GetTabStripModel();

    content::WebContents* last_contents =
        drag_data_.tab_drag_data_.back().contents;
    const int index_of_last_item =
        attached_model->GetIndexOfWebContents(last_contents);
    if (initial_move_) {
      // TabDragContext determines if the tabs needs to be animated
      // based on model position. This means we need to invoke
      // LayoutDraggedTabsAt before changing the model.
      drag_position_delegate_->LayoutDraggedViewsAt(
          views, drag_data_.source_view_drag_data()->attached_view,
          dragged_view_point, initial_move_);
      did_layout = true;
    }

    // Only record the metric when the tab is moved to a different index.
    if (!just_attached && index_of_last_item != to_index) {
      attached_context_->GetWidget()
          ->GetCompositor()
          ->RequestSuccessfulPresentationTimeForNextFrame(base::BindOnce(
              [](base::TimeTicks now,
                 const viz::FrameTimingDetails& frame_timing_details) {
                base::TimeTicks presentation_timestamp =
                    frame_timing_details.presentation_feedback.timestamp;
                UmaHistogramTimes(kDragAmongTabsPresentationTimeHistogram,
                                  presentation_timestamp - now);
              },
              base::TimeTicks::Now()));
    }

    if (drag_data_.group_header_drag_data_.has_value()) {
      attached_model->MoveGroupTo(
          drag_data_.group_header_drag_data_.value().group, to_index);
    } else {
      attached_model->MoveSelectedTabsTo(
          to_index, CalculateGroupForDraggedTabs(to_index));
    }

    // Move may do nothing in certain situations (such as when dragging pinned
    // tabs). Make sure the tabstrip actually changed before updating
    // `last_move_attached_context_loc_`.
    if (index_of_last_item !=
        attached_model->GetIndexOfWebContents(last_contents)) {
      last_move_attached_context_loc_ = point_in_attached_context.x();
    }
  }

  if (!did_layout) {
    drag_position_delegate_->LayoutDraggedViewsAt(
        views, drag_data_.source_view_drag_data()->attached_view,
        dragged_view_point, initial_move_);
  }

  // Snap the non-dragged tabs to their ideal bounds now, otherwise those tabs
  // will animate to those bounds after attach, which looks flickery/bad. See
  // https://crbug.com/40862994.
  if (just_attached && !initial_move_) {
    drag_position_delegate_->ForceLayout();
  }

  initial_move_ = false;
}

gfx::Rect DraggingTabsSession::GetDraggedViewTabStripBounds(
    gfx::Point tab_strip_point) const {
  // attached_view is null when inserting into a new context.
  if (drag_data_.source_view_drag_data()->attached_view) {
    std::vector<gfx::Rect> all_bounds =
        drag_position_delegate_->CalculateBoundsForDraggedViews(
            drag_data_.attached_views());
    int total_width = all_bounds.back().right() - all_bounds.front().x();
    return gfx::Rect(
        tab_strip_point.x(), tab_strip_point.y(), total_width,
        drag_data_.source_view_drag_data()->attached_view->height());
  }

  return gfx::Rect(tab_strip_point.x(), tab_strip_point.y(),
                   TabStyle::Get()->GetStandardWidth(/*is_split=*/false),
                   GetLayoutConstant(LayoutConstant::kTabHeight));
}

bool DraggingTabsSession::AreTabsConsecutive() const {
  for (size_t i = 1; i < drag_data_.tab_drag_data_.size(); ++i) {
    const std::optional<int> previous_source_index =
        drag_data_.tab_drag_data_[i - 1].source_model_index;
    const std::optional<int> source_index =
        drag_data_.tab_drag_data_[i].source_model_index;
    if (previous_source_index.has_value() && source_index.has_value() &&
        previous_source_index.value() + 1 != source_index.value()) {
      return false;
    }
  }
  return true;
}

std::optional<tab_groups::TabGroupId>
DraggingTabsSession::CalculateGroupForDraggedTabs(int to_index) {
  TabStripModel* attached_model = attached_context_->GetTabStripModel();

  // If a group is moved, the drag cannot be inserted into another group.
  for (const TabDragData& tab_drag_datum : drag_data_.tab_drag_data_) {
    if (tab_drag_datum.view_type == TabSlotView::ViewType::kTabGroupHeader) {
      return std::nullopt;
    }
  }

  // Get the proposed tabstrip model assuming the selection has taken place.
  std::pair<std::optional<int>, std::optional<int>> adjacent_indices =
      attached_model->GetAdjacentTabsAfterSelectedMove(
          base::PassKey<DraggingTabsSession>(), to_index);

  const ui::ListSelectionModel::SelectedIndices selected =
      attached_model->selection_model()
          .GetListSelectionModel()
          .selected_indices();

  // Pinned tabs cannot be grouped, so we only change the group membership of
  // unpinned tabs.
  std::vector<int> selected_unpinned;
  for (size_t selected_index : selected) {
    if (!attached_model->IsTabPinned(selected_index)) {
      selected_unpinned.push_back(selected_index);
    }
  }

  if (selected_unpinned.empty()) {
    return std::nullopt;
  }

  // When focus mode is active, dragging unpinned tabs within the tab strip
  // must remain in the focused group.
  const std::optional<tab_groups::TabGroupId> focused_group =
      attached_model->GetFocusedGroup();
  if (focused_group.has_value()) {
    return focused_group;
  }

  std::optional<tab_groups::TabGroupId> left_group =
      adjacent_indices.first.has_value()
          ? attached_model->GetTabGroupForTab(adjacent_indices.first.value())
          : std::nullopt;
  std::optional<tab_groups::TabGroupId> right_group =
      adjacent_indices.second.has_value()
          ? attached_model->GetTabGroupForTab(adjacent_indices.second.value())
          : std::nullopt;
  std::optional<tab_groups::TabGroupId> current_group =
      attached_model->GetTabGroupForTab(selected_unpinned[0]);

  if (left_group == right_group) {
    return left_group;
  }

  // If the tabs on the left and right have different group memberships,
  // including if one is ungrouped or nonexistent, change the group of the
  // dragged tab based on whether it is "leaning" toward the left or the
  // right of the gap. If the tab is centered in the gap, make the tab
  // ungrouped.

  const TabSlotView* left_most_selected_tab =
      drag_position_delegate_->GetTabAt(selected_unpinned[0]);

  const int buffer = left_most_selected_tab->width() / 4;

  // The tab's bounds are larger than what visually appears in order to include
  // space for the rounded feet. Adding {tab_left_inset} to the horizontal
  // bounds of the tab results in the x position that would be drawn when there
  // are no feet showing.
  const int tab_left_inset = TabStyle::Get()->GetTabOverlap() / 2;

  const auto tab_bounds_in_drag_context_coords = [this](int model_index) {
    const TabSlotView* const tab =
        drag_position_delegate_->GetTabAt(model_index);
    return ToEnclosingRect(views::View::ConvertRectToTarget(
        tab->parent(), base::to_address(attached_context_),
        gfx::RectF(tab->bounds())));
  };

  // Use the left edge for a reliable fallback, e.g. if this is the leftmost
  // tab or there is a group header to the immediate left.
  int left_edge =
      adjacent_indices.first.has_value()
          ? tab_bounds_in_drag_context_coords(adjacent_indices.first.value())
                    .right() -
                tab_left_inset
          : tab_left_inset;

  // Extra polish: Prefer staying in an existing group, if any. This prevents
  // tabs at the edge of the group from flickering between grouped and
  // ungrouped. It also gives groups a slightly "sticky" feel while dragging.
  if (left_group.has_value() && left_group == current_group) {
    left_edge += buffer;
  }
  if (right_group.has_value() && right_group == current_group &&
      left_edge > tab_left_inset) {
    left_edge -= buffer;
  }

  const int left_most_selected_x_position =
      left_most_selected_tab->x() + tab_left_inset;

  if (left_group.has_value() &&
      !attached_model->IsGroupCollapsed(left_group.value())) {
    // Take the dragged tabs out of left_group if they are at the rightmost edge
    // of the tabstrip. This happens when the tabstrip is full and the dragged
    // tabs are as far right as they can go without being pulled out into a new
    // window. In this case, since the dragged tabs can't move further right in
    // the tabstrip, it will never go "beyond" the left_group and therefore
    // never leave it unless we add this check. See crbug.com/40723040.
    if (tab_bounds_in_drag_context_coords(selected_unpinned.back()).right() >=
        drag_position_delegate_->TabDragAreaEndX()) {
      return std::nullopt;
    }

    if (left_most_selected_x_position <= left_edge - buffer) {
      return left_group;
    }
  }
  if ((left_most_selected_x_position >= left_edge + buffer) &&
      right_group.has_value() &&
      !attached_model->IsGroupCollapsed(right_group.value())) {
    return right_group;
  }
  return std::nullopt;
}

gfx::Point DraggingTabsSession::GetAttachedDragPoint(
    gfx::Point point_in_screen) {
  const gfx::Point tab_loc = views::View::ConvertPointFromScreen(
      base::to_address(attached_context_), point_in_screen);
  const int x =
      attached_context_->GetMirroredXInView(tab_loc.x()) - mouse_offset_;

  // If the width needed for the `attached_views_` is greater than what is
  // available in the tab drag area the attached drag point should simply be the
  // beginning of the tab strip. Once attached the `attached_views_` will simply
  // overflow as usual (see https://crbug.com/40791711).
  const int max_x = std::max(
      0, drag_position_delegate_->GetTabDragAreaWidth() -
             TabStrip::GetSizeNeededForViews(drag_data_.attached_views()));
  return gfx::Point(std::clamp(x, 0, max_x), 0);
}
