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

#include "components/feed/content/feed_offline_host.h"

#include <algorithm>
#include <map>
#include <memory>
#include <utility>

#include "base/bind.h"
#include "base/location.h"
#include "base/task/post_task.h"
#include "base/test/scoped_task_environment.h"
#include "base/threading/thread_task_runner_handle.h"
#include "components/feed/core/content_metadata.h"
#include "components/offline_pages/core/client_namespace_constants.h"
#include "components/offline_pages/core/offline_page_item.h"
#include "components/offline_pages/core/offline_page_types.h"
#include "components/offline_pages/core/page_criteria.h"
#include "components/offline_pages/core/prefetch/stub_prefetch_service.h"
#include "components/offline_pages/core/stub_offline_page_model.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"

namespace feed {

namespace {

using offline_pages::MultipleOfflinePageItemCallback;
using offline_pages::OfflinePageItem;
using offline_pages::OfflinePageModel;
using offline_pages::PageCriteria;
using offline_pages::PrefetchSuggestion;
using offline_pages::StubOfflinePageModel;
using offline_pages::StubPrefetchService;
using offline_pages::SuggestionsProvider;

constexpr char kUrl1[] = "https://www.one.com/";
constexpr char kUrl2[] = "https://www.two.com/";
constexpr char kUrl3[] = "https://www.three.com/";

constexpr char kOne[] = "One";
constexpr char kTwo[] = "Two";
constexpr char kThree[] = "Three";

MATCHER_P(EqualsSpec, expected, "") {
  return arg.spec() == expected;
}

class TestOfflinePageModel : public StubOfflinePageModel {
 public:
  void AddOfflinedPage(const std::string& url,
                       const std::string& original_url,
                       int64_t offline_id,
                       base::Time creation_time,
                       std::string name_space) {
    OfflinePageItem item;
    item.url = GURL(url);
    item.original_url_if_different = GURL(original_url);
    item.offline_id = offline_id;
    item.creation_time = creation_time;
    item.client_id = offline_pages::ClientId(name_space, "");
    url_to_offline_page_item_.emplace(url, item);
    if (!original_url.empty()) {
      url_to_offline_page_item_.emplace(original_url, item);
    }
  }

  void AddOfflinedPage(const std::string& url, int64_t offline_id) {
    AddOfflinedPage(url, "", offline_id, base::Time(), "");
  }

  MOCK_METHOD1(AddObserver, void(Observer*));
  MOCK_METHOD1(RemoveObserver, void(Observer*));

 private:
  void GetPagesWithCriteria(const PageCriteria& criteria,
                            MultipleOfflinePageItemCallback callback) override {
    // Feed should ignore tab-bound pages.
    EXPECT_TRUE(criteria.exclude_tab_bound_pages);

    auto iter = url_to_offline_page_item_.equal_range(criteria.url.spec());
    std::vector<OfflinePageItem> ret;
    ret.resize(std::distance(iter.first, iter.second));
    std::transform(iter.first, iter.second, ret.begin(),
                   [](auto pair) { return pair.second; });

    base::ThreadTaskRunnerHandle::Get()->PostTask(
        FROM_HERE, base::BindOnce(std::move(callback), std::move(ret)));
  }

  // Maps URLs to OfflinePageItem. Items with both |urls| and |original_url|
  // will be inserted at both locations in the multimap.
  std::multimap<std::string, OfflinePageItem> url_to_offline_page_item_;
};

class TestPrefetchService : public StubPrefetchService {
 public:
  MOCK_METHOD1(SetSuggestionProvider, void(SuggestionsProvider*));
  MOCK_METHOD0(NewSuggestionsAvailable, void());
  MOCK_METHOD1(RemoveSuggestion, void(GURL));
};

void IgnoreStatus(std::vector<std::string> result) {}

void CopyStatus(std::vector<std::string>* out,
                std::vector<std::string> result) {
  *out = std::move(result);
}

void CopySuggestions(std::vector<PrefetchSuggestion>* out,
                     std::vector<PrefetchSuggestion> result) {
  *out = std::move(result);
}

}  // namespace

class FeedOfflineHostTest : public ::testing::Test {
 public:
  TestOfflinePageModel* offline_page_model() { return &offline_page_model_; }
  FeedOfflineHost* host() { return host_.get(); }
  TestPrefetchService* prefetch_service() { return &prefetch_service_; }
  int get_suggestion_consumed_count() { return suggestion_consumed_count_; }
  int get_suggestions_shown_count() { return suggestions_shown_count_; }
  int get_get_known_content_count() { return get_known_content_count_; }
  const std::vector<std::pair<std::string, bool>>& get_status_notifications() {
    return status_notifications_;
  }

  void RunUntilIdle() { scoped_task_environment_.RunUntilIdle(); }

  void SetupHost() {
    EXPECT_CALL(*offline_page_model(), AddObserver(testing::_))
        .Times(1)
        .RetiresOnSaturation();
    EXPECT_CALL(*offline_page_model(), RemoveObserver(testing::_))
        .Times(1)
        .RetiresOnSaturation();
    ResetHost();
    InitHost();
  }

  void ResetHost() {
    host_ = std::make_unique<FeedOfflineHost>(
        offline_page_model(), prefetch_service(),
        base::BindRepeating(&FeedOfflineHostTest::OnSuggestionConsumed,
                            base::Unretained(this)),
        base::BindRepeating(&FeedOfflineHostTest::OnSuggestionsShown,
                            base::Unretained(this)));
  }

  void InitHost() {
    host()->Initialize(
        base::BindRepeating(&FeedOfflineHostTest::OnGetKnownContentRequested,
                            base::Unretained(this)),
        base::BindRepeating(&FeedOfflineHostTest::NotifyStatusChange,
                            base::Unretained(this)));
  }

 protected:
  FeedOfflineHostTest() { SetupHost(); }

 private:
  void OnSuggestionConsumed() { ++suggestion_consumed_count_; }
  void OnSuggestionsShown() { ++suggestions_shown_count_; }
  void OnGetKnownContentRequested() { ++get_known_content_count_; }

  void NotifyStatusChange(const std::string& url, bool available_offline) {
    status_notifications_.emplace_back(url, available_offline);
  }

  base::test::ScopedTaskEnvironment scoped_task_environment_;
  TestOfflinePageModel offline_page_model_;
  TestPrefetchService prefetch_service_;
  std::unique_ptr<FeedOfflineHost> host_;
  int suggestion_consumed_count_ = 0;
  int suggestions_shown_count_ = 0;
  int get_known_content_count_ = 0;
  std::vector<std::pair<std::string, bool>> status_notifications_;
};

TEST_F(FeedOfflineHostTest, ReportArticleListViewed) {
  EXPECT_EQ(0, get_suggestion_consumed_count());
  host()->ReportArticleListViewed();
  EXPECT_EQ(1, get_suggestion_consumed_count());
  host()->ReportArticleListViewed();
  EXPECT_EQ(2, get_suggestion_consumed_count());
  EXPECT_EQ(0, get_suggestions_shown_count());
}

TEST_F(FeedOfflineHostTest, OnSuggestionsShown) {
  EXPECT_EQ(0, get_suggestions_shown_count());
  host()->ReportArticleViewed(GURL(kUrl1));
  EXPECT_EQ(1, get_suggestions_shown_count());
  host()->ReportArticleViewed(GURL(kUrl1));
  EXPECT_EQ(2, get_suggestions_shown_count());
  EXPECT_EQ(0, get_suggestion_consumed_count());
}

TEST_F(FeedOfflineHostTest, GetOfflineStatusEmpty) {
  std::vector<std::string> actual;
  host()->GetOfflineStatus({}, base::BindOnce(&CopyStatus, &actual));
  RunUntilIdle();

  EXPECT_EQ(0U, actual.size());
}

TEST_F(FeedOfflineHostTest, GetOfflineStatusMiss) {
  offline_page_model()->AddOfflinedPage(kUrl1, 4);

  std::vector<std::string> actual;
  host()->GetOfflineStatus({kUrl2}, base::BindOnce(&CopyStatus, &actual));
  RunUntilIdle();

  EXPECT_EQ(0U, actual.size());
  EXPECT_FALSE(host()->GetOfflineId(kUrl1).has_value());
  EXPECT_FALSE(host()->GetOfflineId(kUrl2).has_value());
}

TEST_F(FeedOfflineHostTest, GetOfflineStatusHit) {
  offline_page_model()->AddOfflinedPage(kUrl1, 4);
  offline_page_model()->AddOfflinedPage(kUrl2, 5);
  offline_page_model()->AddOfflinedPage(kUrl3, 6);

  std::vector<std::string> actual;
  host()->GetOfflineStatus({kUrl1, kUrl2},
                           base::BindOnce(&CopyStatus, &actual));

  EXPECT_EQ(0U, actual.size());
  RunUntilIdle();

  EXPECT_EQ(2U, actual.size());
  EXPECT_TRUE(actual[0] == kUrl1 || actual[1] == kUrl1);
  EXPECT_TRUE(actual[0] == kUrl2 || actual[1] == kUrl2);
  EXPECT_EQ(host()->GetOfflineId(kUrl1).value(), 4);
  EXPECT_EQ(host()->GetOfflineId(kUrl2).value(), 5);
  EXPECT_FALSE(host()->GetOfflineId(kUrl3).has_value());
}

TEST_F(FeedOfflineHostTest, GetOfflineIdOriginalUrl) {
  offline_page_model()->AddOfflinedPage(kUrl1, kUrl2, 4, base::Time(), "");

  std::vector<std::string> actual;
  host()->GetOfflineStatus({kUrl2}, base::BindOnce(&CopyStatus, &actual));
  RunUntilIdle();

  EXPECT_EQ(1U, actual.size());
  EXPECT_EQ(kUrl2, actual[0]);
  EXPECT_FALSE(host()->GetOfflineId(kUrl1).has_value());
  EXPECT_EQ(host()->GetOfflineId(kUrl2).value(), 4);
}

TEST_F(FeedOfflineHostTest, GetOfflineIdRequestUrl) {
  offline_page_model()->AddOfflinedPage(kUrl2, kUrl1, 4, base::Time(), "");

  std::vector<std::string> actual;
  host()->GetOfflineStatus({kUrl2}, base::BindOnce(&CopyStatus, &actual));
  RunUntilIdle();

  EXPECT_EQ(1U, actual.size());
  EXPECT_EQ(kUrl2, actual[0]);
  EXPECT_FALSE(host()->GetOfflineId(kUrl1).has_value());
  EXPECT_EQ(host()->GetOfflineId(kUrl2).value(), 4);
}

TEST_F(FeedOfflineHostTest, GetOfflineIdNewer) {
  offline_page_model()->AddOfflinedPage(kUrl1, "", 4, base::Time(), "");
  offline_page_model()->AddOfflinedPage(
      kUrl1, "", 5, base::Time() + base::TimeDelta::FromHours(1), "");

  std::vector<std::string> actual;
  host()->GetOfflineStatus({kUrl1}, base::BindOnce(&CopyStatus, &actual));
  RunUntilIdle();

  EXPECT_EQ(1U, actual.size());
  EXPECT_EQ(kUrl1, actual[0]);
  EXPECT_EQ(host()->GetOfflineId(kUrl1).value(), 5);
}

TEST_F(FeedOfflineHostTest, GetOfflineIdNamespace) {
  // Even though id of 5 is newer, id of 4 will be chosen because it has the
  // preferred namespace.
  offline_page_model()->AddOfflinedPage(
      kUrl1, "", 4, base::Time(), offline_pages::kSuggestedArticlesNamespace);
  offline_page_model()->AddOfflinedPage(
      kUrl1, "", 5, base::Time() + base::TimeDelta::FromHours(1), "");

  std::vector<std::string> actual;
  host()->GetOfflineStatus({kUrl1}, base::BindOnce(&CopyStatus, &actual));
  RunUntilIdle();

  EXPECT_EQ(1U, actual.size());
  EXPECT_EQ(kUrl1, actual[0]);
  EXPECT_EQ(host()->GetOfflineId(kUrl1).value(), 4);
}

TEST_F(FeedOfflineHostTest, GetCurrentArticleSuggestions) {
  std::vector<PrefetchSuggestion> actual;
  host()->GetCurrentArticleSuggestions(
      base::BindOnce(&CopySuggestions, &actual));
  EXPECT_EQ(1, get_get_known_content_count());
  EXPECT_EQ(0U, actual.size());

  ContentMetadata metadata;
  metadata.url = kUrl1;
  metadata.title = kOne;
  metadata.time_published = base::Time();
  metadata.image_url = kUrl2;
  metadata.publisher = kTwo;
  metadata.favicon_url = kUrl3;
  metadata.snippet = kThree;
  host()->OnGetKnownContentDone({std::move(metadata)});

  EXPECT_EQ(1U, actual.size());
  EXPECT_EQ(kUrl1, actual[0].article_url.spec());
  EXPECT_EQ(kOne, actual[0].article_title);
  EXPECT_EQ(kTwo, actual[0].article_attribution);
  EXPECT_EQ(kThree, actual[0].article_snippet);
  EXPECT_EQ(kUrl2, actual[0].thumbnail_url.spec());
  EXPECT_EQ(kUrl3, actual[0].favicon_url.spec());
}

TEST_F(FeedOfflineHostTest, GetCurrentArticleSuggestionsMultiple) {
  std::vector<PrefetchSuggestion> suggestions1;
  host()->GetCurrentArticleSuggestions(
      base::BindOnce(&CopySuggestions, &suggestions1));
  EXPECT_EQ(1, get_get_known_content_count());
  std::vector<PrefetchSuggestion> suggestions2;
  host()->GetCurrentArticleSuggestions(
      base::BindOnce(&CopySuggestions, &suggestions2));
  // This second GetCurrentArticleSuggestions() should not re-trigger since the
  // host should know there's an outstanding request.
  EXPECT_EQ(1, get_get_known_content_count());

  // Use both url and title, url goes through a GURL and isn't really moved all
  // the way, but title should actually be moved into one of the results.
  ContentMetadata metadata1;
  metadata1.url = kUrl1;
  metadata1.title = kOne;
  ContentMetadata metadata2;
  metadata2.url = kUrl2;
  metadata2.title = kTwo;
  host()->OnGetKnownContentDone({std::move(metadata1), std::move(metadata2)});

  EXPECT_EQ(2U, suggestions1.size());
  EXPECT_EQ(kUrl1, suggestions1[0].article_url.spec());
  EXPECT_EQ(kOne, suggestions1[0].article_title);
  EXPECT_EQ(kUrl2, suggestions1[1].article_url.spec());
  EXPECT_EQ(kTwo, suggestions1[1].article_title);
  EXPECT_EQ(2U, suggestions2.size());
  EXPECT_EQ(kUrl1, suggestions2[0].article_url.spec());
  EXPECT_EQ(kOne, suggestions2[0].article_title);
  EXPECT_EQ(kUrl2, suggestions2[1].article_url.spec());
  EXPECT_EQ(kTwo, suggestions2[1].article_title);

  // Now perform another GetCurrentArticleSuggestions and make sure the
  // originally bound callbacks are not invoked.
  std::vector<PrefetchSuggestion> suggestions3;
  host()->GetCurrentArticleSuggestions(
      base::BindOnce(&CopySuggestions, &suggestions3));
  EXPECT_EQ(2, get_get_known_content_count());

  ContentMetadata metadata3;
  metadata3.url = kUrl3;
  metadata3.title = kThree;
  host()->OnGetKnownContentDone({std::move(metadata3)});

  EXPECT_EQ(2U, suggestions1.size());
  EXPECT_EQ(2U, suggestions2.size());
  EXPECT_EQ(1U, suggestions3.size());
  EXPECT_EQ(kUrl3, suggestions3[0].article_url.spec());
  EXPECT_EQ(kThree, suggestions3[0].article_title);
}

TEST_F(FeedOfflineHostTest, OfflinePageAdded) {
  OfflinePageItem added_page;
  added_page.url = GURL(kUrl1);
  added_page.original_url_if_different = GURL(kUrl2);
  added_page.offline_id = 4;

  host()->OfflinePageAdded(nullptr, added_page);

  EXPECT_EQ(1U, get_status_notifications().size());
  EXPECT_EQ(kUrl2, get_status_notifications()[0].first);
  EXPECT_TRUE(get_status_notifications()[0].second);
  EXPECT_EQ(host()->GetOfflineId(kUrl2).value(), 4);
}

TEST_F(FeedOfflineHostTest, OfflinePageDeleted) {
  offline_page_model()->AddOfflinedPage(kUrl1, 4);
  host()->GetOfflineStatus({kUrl1}, base::BindOnce(&IgnoreStatus));
  RunUntilIdle();
  EXPECT_EQ(host()->GetOfflineId(kUrl1).value(), 4);
  OfflinePageItem page_item;
  page_item.url = GURL(kUrl1);

  host()->OfflinePageDeleted(page_item);

  EXPECT_EQ(1U, get_status_notifications().size());
  EXPECT_EQ(kUrl1, get_status_notifications()[0].first);
  EXPECT_FALSE(get_status_notifications()[0].second);
  EXPECT_FALSE(host()->GetOfflineId(kUrl1).has_value());
}

TEST_F(FeedOfflineHostTest, OnNoListeners) {
  offline_page_model()->AddOfflinedPage(kUrl1, 4);
  host()->GetOfflineStatus({kUrl1}, base::BindOnce(&IgnoreStatus));
  RunUntilIdle();
  EXPECT_EQ(host()->GetOfflineId(kUrl1).value(), 4);

  host()->OnNoListeners();

  EXPECT_FALSE(host()->GetOfflineId(kUrl1).has_value());
}

TEST_F(FeedOfflineHostTest, NoAddObserverWithoutInitialize) {
  // Normally SetupHost() will set all observer expectations. Here we explicitly
  // do not expect AddObserver and then reset the host.
  EXPECT_CALL(*offline_page_model(), RemoveObserver(testing::_))
      .Times(1)
      .RetiresOnSaturation();
  ResetHost();
}

TEST_F(FeedOfflineHostTest, SetSuggestionProviderNotCalledSynchronously) {
  EXPECT_CALL(*prefetch_service(), SetSuggestionProvider(testing::_)).Times(0);
}

TEST_F(FeedOfflineHostTest, SetSuggestionProviderCalledAsync) {
  EXPECT_CALL(*prefetch_service(), SetSuggestionProvider(testing::_)).Times(1);
  RunUntilIdle();
}

TEST_F(FeedOfflineHostTest, OnNewContentReceived) {
  EXPECT_CALL(*prefetch_service(), NewSuggestionsAvailable()).Times(1);
  host()->OnNewContentReceived();
}

TEST_F(FeedOfflineHostTest, RemoveZeroSuggestions) {
  EXPECT_CALL(*prefetch_service(), RemoveSuggestion(testing::_)).Times(0);
  host()->OnContentRemoved({});
}

TEST_F(FeedOfflineHostTest, RemoveOneSuggestion) {
  EXPECT_CALL(*prefetch_service(), RemoveSuggestion(EqualsSpec(kUrl1)))
      .Times(1);
  host()->OnContentRemoved({kUrl1});
}

TEST_F(FeedOfflineHostTest, RemoveTwoSuggestions) {
  EXPECT_CALL(*prefetch_service(), RemoveSuggestion(EqualsSpec(kUrl1)))
      .Times(1);
  EXPECT_CALL(*prefetch_service(), RemoveSuggestion(EqualsSpec(kUrl2)))
      .Times(1);
  host()->OnContentRemoved({kUrl1, kUrl2});
}

}  // namespace feed
