// Copyright 2017 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/payments/payment_request_sheet_controller.h"

#include <utility>

#include "base/functional/bind.h"
#include "base/memory/raw_ptr.h"
#include "build/build_config.h"
#include "chrome/browser/ui/color/chrome_color_id.h"
#include "chrome/browser/ui/views/payments/payment_request_dialog_view.h"
#include "chrome/browser/ui/views/payments/payment_request_views_util.h"
#include "components/strings/grit/components_strings.h"
#include "components/vector_icons/vector_icons.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/ui_base_features.h"
#include "ui/color/color_id.h"
#include "ui/color/color_provider.h"
#include "ui/compositor/layer.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/border.h"
#include "ui/views/controls/button/image_button.h"
#include "ui/views/controls/button/image_button_factory.h"
#include "ui/views/controls/button/md_text_button.h"
#include "ui/views/controls/scroll_view.h"
#include "ui/views/focus/focus_search.h"
#include "ui/views/layout/box_layout_view.h"
#include "ui/views/layout/table_layout_view.h"
#include "ui/views/metadata/view_factory.h"
#include "ui/views/painter.h"

namespace payments::internal {

// This class is the actual sheet that gets pushed on the view_stack_. It
// implements views::FocusTraversable to trap focus within its hierarchy. This
// way, focus doesn't leave the topmost sheet on the view stack to go on views
// that happen to be underneath.
// This class also overrides RequestFocus() to allow consumers to specify which
// view should be focused first (through SetFirstFocusableView). If no initial
// view is specified, the first view added to the hierarchy will get focus when
// this SheetView's RequestFocus() is called.
class SheetView : public views::BoxLayoutView, public views::FocusTraversable {
  METADATA_HEADER(SheetView, views::BoxLayoutView)

 public:
  explicit SheetView(
      const base::RepeatingCallback<void(bool*, const ui::Event&)>&
          enter_key_accelerator_callback)
      : enter_key_accelerator_callback_(enter_key_accelerator_callback) {
    if (enter_key_accelerator_callback_) {
      AddAccelerator(enter_key_accelerator_);
    }
  }
  SheetView(const SheetView&) = delete;
  SheetView& operator=(const SheetView&) = delete;
  ~SheetView() override = default;

  // Sets |view| as the first focusable view in this pane. If it's nullptr, the
  // fallback is to use focus_search_ to find the first focusable view.
  void SetFirstFocusableView(views::View* view) { first_focusable_ = view; }

 private:
  // views::FocusTraversable:
  views::FocusSearch* GetFocusSearch() override { return focus_search_.get(); }

  views::FocusTraversable* GetFocusTraversableParent() override {
    return parent()->GetFocusTraversable();
  }

  views::View* GetFocusTraversableParentView() override { return this; }

  // views::View:
  views::FocusTraversable* GetPaneFocusTraversable() override { return this; }

  void RequestFocus() override {
    // In accessibility contexts, we want to focus the title of the sheet.
    views::View* title =
        GetViewByID(static_cast<int>(payments::DialogViewID::SHEET_TITLE));
    views::FocusManager* focus = GetFocusManager();
    DCHECK(focus);

    title->RequestFocus();

    // RequestFocus only works if we are in an accessible context, and is a
    // no-op otherwise. Thus, if the focused view didn't change, we need to
    // proceed with setting the focus for standard usage.
    if (focus->GetFocusedView() == title) {
      return;
    }

    views::View* first_focusable = first_focusable_;

    if (!first_focusable) {
      views::FocusTraversable* dummy_focus_traversable;
      views::View* dummy_focus_traversable_view;
      first_focusable = focus_search_->FindNextFocusableView(
          nullptr, views::FocusSearch::SearchDirection::kForwards,
          views::FocusSearch::TraversalDirection::kDown,
          views::FocusSearch::StartingViewPolicy::kSkipStartingView,
          views::FocusSearch::AnchoredDialogPolicy::kCanGoIntoAnchoredDialog,
          &dummy_focus_traversable, &dummy_focus_traversable_view);
    }

    if (first_focusable) {
      first_focusable->RequestFocus();
    }
  }

  bool AcceleratorPressed(const ui::Accelerator& accelerator) override {
    if (accelerator != enter_key_accelerator_ ||
        !enter_key_accelerator_callback_) {
      return views::View::AcceleratorPressed(accelerator);
    }

    bool is_enabled = false;
    enter_key_accelerator_callback_.Run(&is_enabled,
                                        enter_key_accelerator_.ToKeyEvent());
    return is_enabled;
  }

  void ViewHierarchyChanged(
      const views::ViewHierarchyChangedDetails& details) override {
    if (!details.is_add && details.child == first_focusable_) {
      first_focusable_ = nullptr;
    }
  }

  void SetVisible(bool visible) override {
    views::View::SetVisible(visible);

    // Screen readers do not ignore invisible elements, so force the screen
    // reader to skip invisible sheet views by making it an ignored leaf node in
    // the accessibility tree.
    GetViewAccessibility().SetIsIgnored(!visible);
    GetViewAccessibility().SetIsLeaf(!visible);
  }

  raw_ptr<views::View> first_focusable_ = nullptr;
  std::unique_ptr<views::FocusSearch> focus_search_ =
      std::make_unique<views::FocusSearch>(/*root=*/this,
                                           /*cycle=*/true,
                                           /*accessibility_mode=*/false);
  ui::Accelerator enter_key_accelerator_{ui::VKEY_RETURN, ui::EF_NONE};
  base::RepeatingCallback<void(bool*, const ui::Event&)>
      enter_key_accelerator_callback_;
};

BEGIN_METADATA(SheetView)
END_METADATA

BEGIN_VIEW_BUILDER(, SheetView, views::BoxLayoutView)
END_VIEW_BUILDER

// A scroll view that displays a separator on the bounds where content is
// scrolled out of view. For example, if the view can be scrolled up to reveal
// more content, the top of the content area will display a separator.
class BorderedScrollView : public views::ScrollView {
  METADATA_HEADER(BorderedScrollView, views::ScrollView)

 public:
  // The painter used by the scroll view to display the border.
  class BorderedScrollViewBorderPainter : public views::Painter {
   public:
    BorderedScrollViewBorderPainter(SkColor color,
                                    BorderedScrollView* scroll_view)
        : color_(color), scroll_view_(scroll_view) {}
    BorderedScrollViewBorderPainter(const BorderedScrollViewBorderPainter&) =
        delete;
    BorderedScrollViewBorderPainter& operator=(
        const BorderedScrollViewBorderPainter&) = delete;
    ~BorderedScrollViewBorderPainter() override = default;

   private:
    // views::Painter:
    gfx::Size GetMinimumSize() const override { return gfx::Size(0, 2); }

    void Paint(gfx::Canvas* canvas, const gfx::Size& size) override {
      if (scroll_view_->GetTopBorder()) {
        canvas->Draw1pxLine(gfx::PointF(), gfx::PointF(size.width(), 0),
                            color_);
      }

      if (scroll_view_->GetBottomBorder()) {
        canvas->Draw1pxLine(gfx::PointF(0, size.height() - 1),
                            gfx::PointF(size.width(), size.height() - 1),
                            color_);
      }
    }

   private:
    SkColor color_;
    // The scroll view that owns the border that owns this painter.
    raw_ptr<BorderedScrollView> scroll_view_;
  };

  BorderedScrollView() : border_insets_(gfx::Insets::VH(1, 0)) {
    SetBackground(views::CreateSolidBackground(ui::kColorDialogBackground));
    // The border color will be set to the theme color in OnThemeChanged, but we
    // need to initialize the view with an empty border so that the correct
    // bounds are computed.
    SetBorder(views::CreateEmptyBorder(border_insets_));
  }

  bool GetTopBorder() const { return GetVisibleRect().y() > 0; }

  bool GetBottomBorder() const {
    return GetVisibleRect().bottom() < contents()->height();
  }

  // views::ScrollView:
  void ScrollToPosition(views::ScrollBar* source, int position) override {
    views::ScrollView::ScrollToPosition(source, position);
    SchedulePaint();
  }
  void OnThemeChanged() override {
    ScrollView::OnThemeChanged();
    SetBorder(views::CreateBorderPainter(
        std::make_unique<BorderedScrollViewBorderPainter>(
            GetColorProvider()->GetColor(ui::kColorSeparator), this),
        border_insets_));
  }

 private:
  gfx::Insets border_insets_;
};

BEGIN_METADATA(BorderedScrollView)
ADD_READONLY_PROPERTY_METADATA(bool, TopBorder)
ADD_READONLY_PROPERTY_METADATA(bool, BottomBorder)
END_METADATA

class PaymentRequestBackArrowButton : public views::ImageButton {
  METADATA_HEADER(PaymentRequestBackArrowButton, views::ImageButton)

 public:
  explicit PaymentRequestBackArrowButton(
      views::Button::PressedCallback back_arrow_callback)
      : views::ImageButton(std::move(back_arrow_callback)) {
    ConfigureVectorImageButton(this);
    views::SetImageFromVectorIconWithColor(
        this,
        features::IsRoundedIconsEnabled() ? vector_icons::kArrowBackIcon
                                          : vector_icons::kBackArrowOldIcon,
        {kColorPaymentsRequestBackArrowButtonIcon,
         kColorPaymentsRequestBackArrowButtonIconDisabled});
    constexpr int kBackArrowSize = 16;
    SetSize(gfx::Size(kBackArrowSize, kBackArrowSize));
    SetFocusBehavior(views::View::FocusBehavior::ALWAYS);
    SetID(static_cast<int>(DialogViewID::BACK_BUTTON));
    GetViewAccessibility().SetName(
        l10n_util::GetStringUTF16(IDS_PAYMENTS_BACK));
  }
};

BEGIN_METADATA(PaymentRequestBackArrowButton)
END_METADATA

}  // namespace payments::internal

DEFINE_VIEW_BUILDER(, payments::internal::SheetView)

namespace payments {

PaymentRequestSheetController::PaymentRequestSheetController(
    base::WeakPtr<PaymentRequestSpec> spec,
    base::WeakPtr<PaymentRequestState> state,
    base::WeakPtr<PaymentRequestDialogView> dialog)
    : spec_(spec), state_(state), dialog_(dialog) {}

PaymentRequestSheetController::~PaymentRequestSheetController() = default;

std::unique_ptr<views::View> PaymentRequestSheetController::CreateView() {
  // Create the footer now so that it's known if there's a primary button or not
  // before creating the sheet view. This way, it's possible to determine
  // whether there's something to do when the user hits enter.
  std::unique_ptr<views::View> footer = CreateFooterView();
  auto sheet_view_builder =
      views::Builder<internal::SheetView>(
          std::make_unique<internal::SheetView>(
              ShouldAccelerateEnterKey()
                  ? base::BindRepeating(&PaymentRequestSheetController::
                                            PerformPrimaryButtonAction,
                                        GetWeakPtr())
                  : base::RepeatingCallback<void(bool*, const ui::Event&)>()))
          .SetOrientation(views::BoxLayout::Orientation::kVertical)
          .CustomConfigure(base::BindOnce(
              [](PaymentRequestSheetController* controller,
                 internal::SheetView* sheet_view) {
                DialogViewID sheet_id;
                if (controller->GetSheetId(&sheet_id)) {
                  sheet_view->SetID(static_cast<int>(sheet_id));
                }

                sheet_view->SetBackground(
                    views::CreateSolidBackground(ui::kColorDialogBackground));

                // Paint the sheets to layers, otherwise the MD buttons (which
                // do paint to a layer) won't do proper clipping.
                sheet_view->SetPaintToLayer();
              },
              base::Unretained(this)))
          .AddChildren(
              views::Builder<views::View>()
                  .CopyAddressTo(&header_view_)
                  .CustomConfigure(base::BindOnce(
                      &PaymentRequestSheetController::PopulateSheetHeaderView,
                      base::Unretained(this))),
              views::Builder<views::View>()
                  .CopyAddressTo(&header_content_separator_container_)
                  .SetUseDefaultFillLayout(true));

  // Add content view
  auto content_view_builder =
      views::Builder<views::BoxLayoutView>()
          .CopyAddressTo(&pane_)
          .SetOrientation(views::BoxLayout::Orientation::kVertical)
          .SetMinimumCrossAxisSize(dialog_->GetActualDialogWidth())
          .AddChild(views::Builder<views::View>()
                        .CopyAddressTo(&content_view_)
                        .SetID(static_cast<int>(DialogViewID::CONTENT_VIEW))
                        .CustomConfigure(
                            base::BindOnce([](views::View* content_view) {
                              content_view->SetPaintToLayer();
                              content_view->layer()->SetFillsBoundsOpaquely(
                                  true);
                              content_view->SetBackground(
                                  views::CreateSolidBackground(
                                      ui::kColorDialogBackground));
                            })));

  if (CanContentViewBeScrollable()) {
    // |content_view| will go into a views::ScrollView so it needs to
    // be sized now otherwise it'll be sized to the ScrollView's
    // viewport height, preventing the scroll bar from ever being
    // shown.
    sheet_view_builder.AddChildren(
        views::Builder<views::ScrollView>(
            DisplayDynamicBorderForHiddenContents()
                ? std::make_unique<internal::BorderedScrollView>()
                : std::make_unique<views::ScrollView>())
            .CopyAddressTo(&scroll_)
            .SetID(static_cast<int>(DialogViewID::PAYMENT_SHEET_SCROLL_VIEW))
            .SetHorizontalScrollBarMode(
                views::ScrollView::ScrollBarMode::kDisabled)
            // Hack to make labels in ScrollView contents wrap to scroll view
            // width.
            // TODO(crbug.com/40280756): Fix this hack.
            .ClipHeightTo(0, std::numeric_limits<int>::max())
            .SetContents(content_view_builder));
  } else {
    sheet_view_builder.AddChildren(content_view_builder);
  }

  auto view = std::move(sheet_view_builder).Build();

  // Ensure the content pane (or the scroll view that may be wrapping the
  // content) fills the dialog.
  view->SetFlexForView(scroll_ ? scroll_ : pane_, 1);

  if (footer) {
    view->AddChildView(std::move(footer));
  }

  UpdateContentView();

  view->SetFirstFocusableView(GetFirstFocusedView());
  return view;
}

void PaymentRequestSheetController::UpdateContentView() {
  // Do not update the view if the payment request is being aborted.
  if (!is_active_) {
    return;
  }

  content_view_->RemoveAllChildViews();
  FillContentView(content_view_);
  RelayoutPane();
}

void PaymentRequestSheetController::UpdateFocus(views::View* focused_view) {
  DialogViewID sheet_id;
  if (GetSheetId(&sheet_id)) {
    internal::SheetView* sheet_view = static_cast<internal::SheetView*>(
        dialog()->GetViewByID(static_cast<int>(sheet_id)));
    // This will be null on first call since it's not been set until CreateView
    // returns, and the first call to UpdateFocus() comes from CreateView.
    if (sheet_view) {
      sheet_view->SetFirstFocusableView(focused_view);
      dialog()->RequestFocus();
    }
  }
}

void PaymentRequestSheetController::RelayoutPane() {
  // Do not update the view if the payment request is being aborted.
  if (!is_active_) {
    return;
  }

  content_view_->InvalidateLayout();
  pane_->SizeToPreferredSize();
  if (scroll_) {
    // Now that the content and its surrounding pane are updated, force a Layout
    // on the ScrollView so that it updates its scroll bars now.
    scroll_->InvalidateLayout();
  }
}

bool PaymentRequestSheetController::ShouldShowPrimaryButton() {
  return true;
}

std::u16string PaymentRequestSheetController::GetPrimaryButtonLabel() {
  return l10n_util::GetStringUTF16(IDS_PAYMENTS_CONTINUE_BUTTON);
}

PaymentRequestSheetController::ButtonCallback
PaymentRequestSheetController::GetPrimaryButtonCallback() {
  return base::BindRepeating(
      [](const base::WeakPtr<PaymentRequestDialogView>& dialog,
         const ui::Event& event) {
        if (dialog->IsInteractive()) {
          dialog->Pay();
        }
      },
      dialog());
}

int PaymentRequestSheetController::GetPrimaryButtonId() {
  return static_cast<int>(DialogViewID::PAY_BUTTON);
}

bool PaymentRequestSheetController::GetPrimaryButtonEnabled() {
  return state()->is_ready_to_pay();
}

bool PaymentRequestSheetController::ShouldShowSecondaryButton() {
  return true;
}

std::u16string PaymentRequestSheetController::GetSecondaryButtonLabel() {
  return l10n_util::GetStringUTF16(IDS_PAYMENTS_CANCEL_PAYMENT);
}

PaymentRequestSheetController::ButtonCallback
PaymentRequestSheetController::GetSecondaryButtonCallback() {
  return base::BindRepeating(&PaymentRequestSheetController::CloseButtonPressed,
                             base::Unretained(this));
}

int PaymentRequestSheetController::GetSecondaryButtonId() {
  return static_cast<int>(DialogViewID::CANCEL_BUTTON);
}

bool PaymentRequestSheetController::ShouldShowHeaderBackArrow() {
  return true;
}

std::unique_ptr<views::View>
PaymentRequestSheetController::CreateExtraFooterView() {
  return nullptr;
}

void PaymentRequestSheetController::PopulateSheetHeaderView(
    views::View* container) {
  DCHECK_EQ(container, header_view_);

  container->SetID(static_cast<int>(DialogViewID::PAYMENT_APP_HEADER));
  container->SetBackground(
      views::CreateSolidBackground(ui::kColorDialogBackground));
  views::BoxLayout* layout =
      container->SetLayoutManager(std::make_unique<views::BoxLayout>());
  layout->set_cross_axis_alignment(
      views::BoxLayout::CrossAxisAlignment::kCenter);
  // Need some spacing if the optional back arrow presents.
  constexpr int kPaddingBetweenArrowAndTitle = 8;
  layout->set_between_child_spacing(kPaddingBetweenArrowAndTitle);

  constexpr int kVerticalInset = 14;
  constexpr int kHeaderHorizontalInset = 16;
  container->SetBorder(views::CreateEmptyBorder(
      gfx::Insets::TLBR(kVerticalInset, kHeaderHorizontalInset, kVerticalInset,
                        kHeaderHorizontalInset)));

  if (ShouldShowHeaderBackArrow()) {
    container->AddChildView(
        std::make_unique<internal::PaymentRequestBackArrowButton>(
            base::BindRepeating(
                &PaymentRequestSheetController::BackButtonPressed,
                base::Unretained(this))));
  }

  layout->SetFlexForView(
      container->AddChildView(
          views::Builder<views::Label>()
              .SetText(GetSheetTitle())
              .SetTextContext(views::style::CONTEXT_DIALOG_TITLE)
              .SetHorizontalAlignment(gfx::ALIGN_LEFT)
              .SetID(static_cast<int>(DialogViewID::SHEET_TITLE))
              .SetFocusBehavior(views::View::FocusBehavior::ACCESSIBLE_ONLY)
              .Build()),
      1);
}

std::unique_ptr<views::View> PaymentRequestSheetController::CreateFooterView() {
  std::unique_ptr<views::View> extra_view = CreateExtraFooterView();
  views::BoxLayoutView* trailing_buttons_container = nullptr;
  bool has_extra_view = !!extra_view;

  auto container =
      views::Builder<views::TableLayoutView>()
          .SetBorder(views::CreateEmptyBorder(16))
          .AddColumn(views::LayoutAlignment::kStart,
                     views::LayoutAlignment::kCenter,
                     views::TableLayout::kFixedSize,
                     views::TableLayout::ColumnSize::kUsePreferred, 0, 0)
          .AddPaddingColumn(1.0, 0)
          .AddColumn(views::LayoutAlignment::kEnd,
                     views::LayoutAlignment::kCenter,
                     views::TableLayout::kFixedSize,
                     views::TableLayout::ColumnSize::kUsePreferred, 0, 0)
          .AddRows(1, views::TableLayout::kFixedSize, 0)
          .AddChildren(
              views::Builder<views::View>(
                  extra_view ? std::move(extra_view)
                             : std::make_unique<views::View>()),
              views::Builder<views::BoxLayoutView>()
                  .CopyAddressTo(&trailing_buttons_container)
                  .SetOrientation(views::BoxLayout::Orientation::kHorizontal)
                  .SetBetweenChildSpacing(kPaymentRequestButtonSpacing)
                  .CustomConfigure(base::BindOnce(
                      [](PaymentRequestSheetController* controller,
                         views::BoxLayoutView* container) {
#if BUILDFLAG(IS_MAC)
                        controller->AddSecondaryButton(container);
                        controller->AddPrimaryButton(container);
#else
                        controller->AddPrimaryButton(container);
                        controller->AddSecondaryButton(container);
#endif  // BUILDFLAG(IS_MAC)
                      },
                      base::Unretained(this))))
          .Build();

  if (!has_extra_view && trailing_buttons_container->children().empty()) {
    // If there's no extra view and no button, return null to signal that no
    // footer should be rendered.
    return nullptr;
  }

  return container;
}

views::View* PaymentRequestSheetController::GetFirstFocusedView() {
  // Do not focus the primary button, per guidelines in
  // docs/security/security-considerations-for-browser-ui.md
  if (secondary_button_ && secondary_button_->GetEnabled()) {
    return secondary_button_;
  }

  DCHECK(content_view_);
  return content_view_;
}

bool PaymentRequestSheetController::GetSheetId(DialogViewID* sheet_id) {
  return false;
}

bool PaymentRequestSheetController::DisplayDynamicBorderForHiddenContents() {
  return true;
}

bool PaymentRequestSheetController::ShouldAccelerateEnterKey() {
  // Subclasses must explicitly opt-into this behavior. Be aware of the risks of
  // enabling click-jacking of the Enter key; see https://crbug.com/40062377
  return false;
}

bool PaymentRequestSheetController::CanContentViewBeScrollable() {
  // Subclasses may override this behaviour to be 'false', e.g. if they have
  // content that may have its own scrollbar like PaymentHandlerWebFlowView.
  return true;
}

void PaymentRequestSheetController::CloseButtonPressed(const ui::Event& event) {
  if (dialog()->IsInteractive()) {
    dialog()->CloseDialog();
  }
}

int PaymentRequestSheetController::GetHeaderHeight() {
  return header_view_
             ? header_view_->GetHeightForWidth(dialog()->GetActualDialogWidth())
             : 0;
}

void PaymentRequestSheetController::AddPrimaryButton(views::View* container) {
  if (ShouldShowPrimaryButton()) {
    views::Builder<views::View>(container)
        .AddChild(views::Builder<views::MdTextButton>()
                      .CopyAddressTo(&primary_button_)
                      .SetCallback(GetPrimaryButtonCallback())
                      .SetText(GetPrimaryButtonLabel())
                      .SetID(GetPrimaryButtonId())
                      .SetEnabled(GetPrimaryButtonEnabled())
                      .SetFocusBehavior(views::View::FocusBehavior::ALWAYS)
                      .SetStyle(ui::ButtonStyle::kProminent))
        .BuildChildren();
  }
}

void PaymentRequestSheetController::AddSecondaryButton(views::View* container) {
  if (ShouldShowSecondaryButton()) {
    views::Builder<views::View>(container)
        .AddChild(views::Builder<views::MdTextButton>()
                      .CopyAddressTo(&secondary_button_)
                      .SetCallback(GetSecondaryButtonCallback())
                      .SetText(GetSecondaryButtonLabel())
                      .SetID(GetSecondaryButtonId())
                      .SetFocusBehavior(views::View::FocusBehavior::ALWAYS))
        .BuildChildren();
  }
}

void PaymentRequestSheetController::PerformPrimaryButtonAction(
    bool* is_enabled,
    const ui::Event& event) {
  // Set |is_enabled| to "true" to prevent other views from handling the event.
  *is_enabled = true;

  if (dialog()->IsInteractive() && primary_button_ &&
      primary_button_->GetEnabled()) {
    ButtonCallback callback = GetPrimaryButtonCallback();
    if (callback) {
      callback.Run(event);
    }
  }
}

void PaymentRequestSheetController::Stop() {
  is_active_ = false;
}

void PaymentRequestSheetController::BackButtonPressed() {
  if (dialog()->IsInteractive()) {
    dialog()->GoBack();
  }
}

}  // namespace payments
