// Copyright 2021 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/metrics/usage_scenario/tab_usage_scenario_tracker.h"

#include "chrome/browser/metrics/usage_scenario/usage_scenario_data_store.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/render_process_host.h"
#include "content/public/browser/visibility.h"
#include "content/public/browser/web_contents.h"
#include "extensions/browser/script_injection_tracker.h"
#include "extensions/common/extension_id.h"
#include "services/metrics/public/cpp/ukm_source_id.h"
#include "ui/display/screen.h"
#include "url/origin.h"

namespace metrics {

namespace {

std::pair<ukm::SourceId, url::Origin> GetNavigationInfoForContents(
    content::WebContents* contents) {
  auto* main_frame = contents->GetPrimaryMainFrame();
  if (!main_frame || main_frame->GetLastCommittedURL().is_empty())
    return std::make_pair(ukm::kInvalidSourceId, url::Origin());

  return std::make_pair(main_frame->GetPageUkmSourceId(),
                        main_frame->GetLastCommittedOrigin());
}

extensions::ExtensionIdSet GetExtensionsThatRanContentScriptsInWebContents(
    content::WebContents* contents) {
  content::RenderFrameHost* main_frame = contents->GetPrimaryMainFrame();
  if (!main_frame) {
    // WebContents is being destroyed.
    return {};
  }
  // Find the complete set of processes in the WebContents' frame tree first so
  // that each is only handled once.
  std::set<content::RenderProcessHost*> processes;
  processes.insert(main_frame->GetProcess());
  main_frame->ForEachRenderFrameHost(
      [&processes](content::RenderFrameHost* frame) {
        processes.insert(frame->GetProcess());
      });

  // Find the set of extensions that ran scripts in these processes.
  //
  // The result may include extra extensions for the following reasons:
  //
  // * ScriptInjectionTracker returns all extensions that have injected scripts
  //   into a process. Multiple pages on the same domain might share a process
  //   so we can't be sure which pages the extension affected.
  // * It also returns extensions that have EVER injected scripts into the
  //   process, even if the extension is no longer active (eg. after navigating
  //   to a new page or removing the extension).
  // * There are renderer-side permission checks that might cause a page to
  //   refuse to inject a script, which ScriptInjectionTracker isn't aware of so
  //   assumes the script was injected.
  //
  // In the first two points, the extension COULD still be affecting pages
  // loaded in the process (due to bugs or malicious code, or just because
  // changes the extension made to the page content had lingering effects).
  //
  // It may also miss extensions for the following reasons:
  //
  // * ScriptInjectionTracker only includes extensions that inject Javascript.
  //   It doesn't track injected CSS scripts, declarative web requests, or
  //   anything else an extension might do that could affect the page content.
  // * The set of processes for the page includes only those that hosted frames,
  //   not workers.
  extensions::ExtensionIdSet extensions;
  for (const auto* process : processes) {
    extensions.merge(extensions::ScriptInjectionTracker::
                         GetExtensionsThatRanContentScriptsInProcess(*process));
  }
  return extensions;
}

}  // namespace

TabUsageScenarioTracker::TabUsageScenarioTracker(
    UsageScenarioDataStoreImpl* usage_scenario_data_store)
    : usage_scenario_data_store_(usage_scenario_data_store) {}

TabUsageScenarioTracker::~TabUsageScenarioTracker() {
  // Make sure that this doesn't get destroyed after destroying the global
  // screen instance.
  DCHECK(display::Screen::Get());
}

void TabUsageScenarioTracker::OnTabAdded(content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  usage_scenario_data_store_->OnTabAdded();

  // Tab is added already visible. It will not get a separate visibility update
  // so we handle the visibility here.
  if (web_contents->GetVisibility() == content::Visibility::VISIBLE) {
    DCHECK(!visible_tabs_.contains(web_contents));
    usage_scenario_data_store_->OnWindowVisible();
    InsertContentsInMapOfVisibleTabs(web_contents);
  }
}

void TabUsageScenarioTracker::OnTabRemoved(content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  OnWebContentsRemoved(web_contents);
  usage_scenario_data_store_->OnTabClosed();
}

void TabUsageScenarioTracker::OnTabReplaced(
    content::WebContents* old_contents,
    content::WebContents* new_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  OnWebContentsRemoved(old_contents);
  DCHECK(!visible_tabs_.contains(old_contents));
  DCHECK(!contents_playing_video_.contains(old_contents));
  DCHECK(!contents_playing_video_fullscreen_.contains(old_contents));

  // Start tracking |new_contents| if needed.
  if (new_contents->GetVisibility() == content::Visibility::VISIBLE)
    OnTabVisibilityChanged(new_contents);
  if (new_contents->IsCurrentlyAudible())
    usage_scenario_data_store_->OnAudioStarts();
}

void TabUsageScenarioTracker::OnTabVisibilityChanged(
    content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);

  auto iter = visible_tabs_.find(web_contents);
  const bool was_visible = iter != visible_tabs_.end();
  const bool is_visible =
      web_contents->GetVisibility() == content::Visibility::VISIBLE;

  // The first content::Visibility::VISIBLE notification is always sent, even
  // if the tab starts in the visible state.
  if (!was_visible && is_visible) {
    usage_scenario_data_store_->OnWindowVisible();

    // If this tab is playing video then record that it became visible.
    if (contents_playing_video_.contains(web_contents)) {
      usage_scenario_data_store_->OnVideoStartsInVisibleTab();
    }

    InsertContentsInMapOfVisibleTabs(web_contents);
  } else if (was_visible && !is_visible) {
    // The tab was previously visible and it's now hidden or occluded.
    OnTabBecameHidden(&iter);
  }
}

void TabUsageScenarioTracker::OnTabDiscarded(
    content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  // Record that the ukm::SourceID associated with this tab isn't visible
  // anymore, if necessary.
  auto iter = visible_tabs_.find(web_contents);
  CHECK_EQ(iter != visible_tabs_.end(),
           web_contents->GetVisibility() == content::Visibility::VISIBLE);
  if (iter != visible_tabs_.end() &&
      iter->second.first != ukm::kInvalidSourceId) {
    usage_scenario_data_store_->OnUkmSourceBecameHidden(iter->second.first,
                                                        iter->second.second);
    // Discard may destroy the associated WebContents or replace the
    // WebContent's primary document with an empty docoument. For the latter
    // case the source id must be invalidated as the UKM source should no longer
    // be considered valid.
    iter->second.first = ukm::kInvalidSourceId;
  }
}

void TabUsageScenarioTracker::OnTabInteraction(
    content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  usage_scenario_data_store_->OnUserInteraction();
}

void TabUsageScenarioTracker::OnTabIsAudibleChanged(
    content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  if (web_contents->IsCurrentlyAudible()) {
    usage_scenario_data_store_->OnAudioStarts();
  } else {
    usage_scenario_data_store_->OnAudioStops();
  }
}

void TabUsageScenarioTracker::OnMediaEffectivelyFullscreenChanged(
    content::WebContents* web_contents,
    bool is_fullscreen) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);

  if (!last_num_displays_.has_value()) {
    last_num_displays_ = GetNumDisplays();
  }

  // Use `last_num_displays_` instead of `GetNumDisplays()` below, to let
  // `OnNumDisplaysChanged()` handle changes in the number of displays.
  if (is_fullscreen) {
    auto [it, inserted] =
        contents_playing_video_fullscreen_.insert(web_contents);
    if (inserted && contents_playing_video_fullscreen_.size() == 1U &&
        last_num_displays_.value() == 1) {
      usage_scenario_data_store_->OnFullScreenVideoStartsOnSingleMonitor();
    }
  } else {
    auto num_removed = contents_playing_video_fullscreen_.erase(web_contents);
    if (num_removed == 1U && contents_playing_video_fullscreen_.empty() &&
        last_num_displays_.value() == 1) {
      usage_scenario_data_store_->OnFullScreenVideoEndsOnSingleMonitor();
    }
  }
}

void TabUsageScenarioTracker::OnMediaDestroyed(
    content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);

  // Return early if the tab is being destroyed. It will be removed from
  // `contents_playing_video_fullscreen_` by
  // TabUsageScenarioTracker::OnWebContentsRemoved().
  //
  // This is an unfortunate workaround for a crash (crbug.com/40880999) that
  // occurs when a Browser that still contains WebContents is destroyed,
  // resulting in:
  // 1. The Browser destroys its `exclusive_access_manager_`.
  // 2. The Browser destroys its WebContents.
  // 3. The WebContents destroys its frames.
  // 4. The frame deletion calls TabUsageScenarioTracker::OnMediaDestroyed
  //    (this method).
  // 5. This method calls HasActiveEffectivelyFullscreenVideo(), which ends
  //    up calling Browser::IsFullscreenForTabOrPending().
  // 6. Browser::IsFullscreenForTabOrPending() accesses a deleted
  //    `exclusive_access_manager_`.
  // According to a DCHECK in ~Browser
  // (https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/ui/browser.cc;l=575;drc=60e7a86ffafb2aafa40cb00214d6b813b41c6f75),
  // a Browser's WebContents should be deleted before the Browser itself is
  // deleted, but it looks like it's not always the case.
  if (web_contents->IsBeingDestroyed())
    return;

  // Destroying a media may cause the WebContents to no longer have a fullscreen
  // media.
  OnMediaEffectivelyFullscreenChanged(
      web_contents, web_contents->HasActiveEffectivelyFullscreenVideo());
}

void TabUsageScenarioTracker::OnPrimaryMainFrameNavigationCommitted(
    content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  usage_scenario_data_store_->OnTopLevelNavigation();

  if (web_contents->GetVisibility() == content::Visibility::VISIBLE) {
    auto iter = visible_tabs_.find(web_contents);
    CHECK(iter != visible_tabs_.end());

    // If there's already an entry with a valid SourceID for this in
    // |visible_tabs_| then it means that there's been a main frame navigation
    // for a visible tab. Records that the SourceID previously associated with
    // this tab isn't visible anymore.
    if (iter->second.first != ukm::kInvalidSourceId) {
      usage_scenario_data_store_->OnUkmSourceBecameHidden(iter->second.first,
                                                          iter->second.second);
    }

    iter->second = GetNavigationInfoForContents(web_contents);
    if (iter->second.first != ukm::kInvalidSourceId) {
      usage_scenario_data_store_->OnUkmSourceBecameVisible(
          iter->second.first, iter->second.second,
          GetExtensionsThatRanContentScriptsInWebContents(web_contents));
    }
  }
}

void TabUsageScenarioTracker::OnVideoStartedPlaying(
    content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  DCHECK(!contents_playing_video_.contains(web_contents));
  contents_playing_video_.insert(web_contents);
  if (visible_tabs_.contains(web_contents)) {
    usage_scenario_data_store_->OnVideoStartsInVisibleTab();
  }
}

void TabUsageScenarioTracker::OnVideoStoppedPlaying(
    content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  DCHECK(contents_playing_video_.contains(web_contents));
  contents_playing_video_.erase(web_contents);
  if (visible_tabs_.contains(web_contents)) {
    usage_scenario_data_store_->OnVideoStopsInVisibleTab();
  }
}

void TabUsageScenarioTracker::OnDisplayAdded(const display::Display&) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  OnNumDisplaysChanged();
}

void TabUsageScenarioTracker::OnDisplaysRemoved(const display::Displays&) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  OnNumDisplaysChanged();
}

int TabUsageScenarioTracker::GetNumDisplays() {
  auto* screen = display::Screen::Get();
  DCHECK(screen);
  return screen->GetNumDisplays();
}

void TabUsageScenarioTracker::OnTabBecameHidden(
    VisibleTabsMap::iterator* visible_tab_iter) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);

  // If this tab is playing video then record that it became non visible.
  content::WebContents* const web_contents = (*visible_tab_iter)->first;
  if (contents_playing_video_.contains(web_contents)) {
    usage_scenario_data_store_->OnVideoStopsInVisibleTab();
  }

  // Record that the ukm::SourceID associated with this tab isn't visible
  // anymore if necessary.
  if ((*visible_tab_iter)->second.first != ukm::kInvalidSourceId) {
    usage_scenario_data_store_->OnUkmSourceBecameHidden(
        (*visible_tab_iter)->second.first, (*visible_tab_iter)->second.second);
  }

  visible_tabs_.erase(*visible_tab_iter);
  usage_scenario_data_store_->OnWindowHidden();
}

void TabUsageScenarioTracker::OnWebContentsRemoved(
    content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  auto iter = visible_tabs_.find(web_contents);
  DCHECK_EQ(iter != visible_tabs_.end(),
            web_contents->GetVisibility() == content::Visibility::VISIBLE);
  // If |web_contents| is tracked in the list of visible WebContents then a
  // synthetic visibility change event should be emitted.
  if (iter != visible_tabs_.end())
    OnTabBecameHidden(&iter);

  // Remove |web_contents| from the list of contents playing video. If
  // necessary, the data store was already informed that a video stopped playing
  // in a visible tab in the OnTabBecameHidden() call above.
  contents_playing_video_.erase(web_contents);

  {
    // Remove |web_contents| from the list of contents will fullscreen media and
    // if necessary, inform the data store that there is no more fullscreen
    // video playing on a single monitor.
    size_t num_removed = contents_playing_video_fullscreen_.erase(web_contents);
    if (num_removed == 1 && contents_playing_video_fullscreen_.empty() &&
        last_num_displays_.has_value() && last_num_displays_.value() == 1) {
      usage_scenario_data_store_->OnFullScreenVideoEndsOnSingleMonitor();
    }
  }

  // If necessary, inform the data store that audio stopped.
  if (web_contents->IsCurrentlyAudible())
    usage_scenario_data_store_->OnAudioStops();
}

void TabUsageScenarioTracker::InsertContentsInMapOfVisibleTabs(
    content::WebContents* web_contents) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  DCHECK(!visible_tabs_.contains(web_contents));
  auto iter = visible_tabs_.emplace(web_contents,
                                    GetNavigationInfoForContents(web_contents));
  if (iter.first->second.first != ukm::kInvalidSourceId) {
    usage_scenario_data_store_->OnUkmSourceBecameVisible(
        iter.first->second.first, iter.first->second.second,
        GetExtensionsThatRanContentScriptsInWebContents(web_contents));
  }
}

void TabUsageScenarioTracker::OnNumDisplaysChanged() {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);

  // Multiple displays can be added or removed before OnDisplayAdded and
  // OnDidRemoveDisplays are dispatched. It is therefore incorrect to assume
  // that the number of displays has increased / decreased compared to
  // `last_num_displays_` following a call to OnDisplayAdded/ OnDisplaysRemoved.

  const int num_displays = GetNumDisplays();

  if (!contents_playing_video_fullscreen_.empty()) {
    // `last_num_displays_` is set when `contents_playing_video_fullscreen_`
    // becomes non-empty.
    CHECK(last_num_displays_.has_value());

    if (num_displays == 1 && last_num_displays_ != 1) {
      usage_scenario_data_store_->OnFullScreenVideoStartsOnSingleMonitor();
    } else if (num_displays != 1 && last_num_displays_ == 1) {
      usage_scenario_data_store_->OnFullScreenVideoEndsOnSingleMonitor();
    }
  }

  last_num_displays_ = num_displays;
}

}  // namespace metrics
