// 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/web_applications/web_app_scope.h"

#include "base/check.h"
#include "base/containers/flat_set.h"
#include "base/numerics/safe_conversions.h"
#include "base/numerics/safe_math.h"
#include "base/strings/string_util.h"
#include "base/types/pass_key.h"
#include "build/buildflag.h"
#include "chrome/browser/web_applications/scope_extension_info.h"
#include "components/webapps/common/web_app_id.h"
#include "url/gurl.h"
#include "url/origin.h"
#include "url/url_constants.h"

#if BUILDFLAG(IS_CHROMEOS)
#include "chrome/browser/web_applications/chromeos_web_app_experiments.h"
#include "chromeos/constants/chromeos_features.h"
#endif

namespace web_app {

namespace {

enum class ScoreBehavior { kMaximumScore, kExitEarlyPositiveNumberFirstMatch };

int GetScopeExtensionsScore(
    const webapps::AppId& app_id,
    const GURL& url,
    const base::flat_set<ScopeExtensionInfo>& validated_scope_extensions,
    ScoreBehavior behavior) {
  if (validated_scope_extensions.empty()) {
    return 0;
  }

  url::Origin origin = url::Origin::Create(url);
  if (origin.opaque() || origin.scheme() != url::kHttpsScheme) {
    return 0;
  }

  int score = 0;
  for (const ScopeExtensionInfo& scope_extension : validated_scope_extensions) {
    CHECK(scope_extension.scope.is_valid());
    // SENSITIVE is safe: GURL::spec() returns a canonicalized URL with a
    // lowercase host.
    if (base::StartsWith(url.spec(), scope_extension.scope.spec(),
                         base::CompareCase::SENSITIVE)) {
      if (behavior == ScoreBehavior::kExitEarlyPositiveNumberFirstMatch) {
        return 1;
      }
      score = std::max(score, base::saturated_cast<int>(
                                  scope_extension.scope.spec().length()));
    }

    // Origins with wildcard e.g. *.foo are saved as https://foo.
    // Ensure while matching that the origin ends with '.foo' and not 'foo',
    // and that the ports match (scheme is already guaranteed to be https).
    if (scope_extension.has_origin_wildcard) {
      const std::string& target_host = origin.host();
      const std::string& extension_host = scope_extension.origin.host();
      // SENSITIVE is safe: url::Origin canonicalizes the host to lowercase
      // for https schemes (enforced by the scheme check above).
      if (origin.port() == scope_extension.origin.port() &&
          base::EndsWith(target_host, extension_host,
                         base::CompareCase::SENSITIVE) &&
          target_host.size() > extension_host.size() &&
          target_host[target_host.size() - extension_host.size() - 1] == '.') {
        if (behavior == ScoreBehavior::kExitEarlyPositiveNumberFirstMatch) {
          return 1;
        }
        score = std::max(score, base::saturated_cast<int>(
                                    scope_extension.scope.spec().length()));
      }
    }
  }
  return score;
}

}  // namespace

WebAppScope::WebAppScope(
    const webapps::AppId& app_id,
    const GURL& scope,
    const base::flat_set<ScopeExtensionInfo>& validated_scope_extensions,
    base::PassKey<WebApp>)
    : app_id_(app_id),
      scope_(scope),
      validated_scope_extensions_(validated_scope_extensions) {
  CHECK(scope_.is_valid());
  CHECK(!app_id_.empty());
}

WebAppScope::~WebAppScope() = default;
WebAppScope::WebAppScope(const WebAppScope&) = default;
WebAppScope::WebAppScope(WebAppScope&&) = default;
WebAppScope& WebAppScope::operator=(const WebAppScope&) = default;
WebAppScope& WebAppScope::operator=(WebAppScope&&) = default;

bool WebAppScope::IsInScope(const GURL& url, WebAppScopeOptions options) const {
  if (!url.is_valid()) {
    return false;
  }

  // DIY apps installed by the user on http sites can have their urls upgraded
  // to https later. While the app is still tied to the http origin, it's nice
  // that the content is now https, and we should consider the https urls
  // in-scope.
  bool origin_matches = url::IsSameOriginWith(scope_, url);
  if (!origin_matches && options.allow_http_to_https_upgrade &&
      scope_.GetScheme() == url::kHttpScheme &&
      url.GetScheme() == url::kHttpsScheme) {
    GURL::Replacements rep;
    rep.SetSchemeStr(url::kHttpsScheme);
    GURL secure_scope = scope_.ReplaceComponents(rep);
    if (url::IsSameOriginWith(secure_scope, url)) {
      origin_matches = true;
    }
  }

  if (origin_matches) {
    // For scopes without paths, return 'true' early (allowing blobs to be in
    // scope).
    if (!scope_.has_path() || scope_.GetPath() == "/") {
      return true;
    }
    if (url.GetScheme() == url::kBlobScheme) {
      // Same-origin blobs can only be in-scope in the above case where the
      // app scope doesn't have a path.
      return false;
    }
    if (base::StartsWith(url.GetPath(), scope_.GetPath(),
                         base::CompareCase::SENSITIVE)) {
      return true;
    }
  }

#if BUILDFLAG(IS_CHROMEOS)
  if (chromeos::features::IsUploadOfficeToCloudEnabled() &&
      ChromeOsWebAppExperiments::GetExtendedScopeScore(app_id_, url.spec()) >
          0) {
    return true;
  }
#endif  // BUILDFLAG(IS_CHROMEOS)

  // Check scope extensions.
  return GetScopeExtensionsScore(
             app_id_, url, validated_scope_extensions_,
             ScoreBehavior::kExitEarlyPositiveNumberFirstMatch) > 0;
}

int WebAppScope::GetScopeScore(const GURL& url,
                               WebAppScopeScoreOptions options) const {
  if (!url.is_valid()) {
    return 0;
  }
  CHECK(!(options.only_consider_scope_extensions &&
          options.exclude_scope_extensions))
      << "Both exclude_scope_extensions && only_consider_scope_extensions set "
         "to true. Both being set at the same time is contradictory";
  if (options.only_consider_scope_extensions) {
    return GetScopeExtensionsScore(app_id_, url, validated_scope_extensions_,
                                   ScoreBehavior::kMaximumScore);
  }
  // Note: This code does not do same-origin checks to be compabible with urls
  // like about:blank or blobs, unlike in the IsInScope method above. We could
  // change this in the future if needed.
  int score = 0;
  if (base::StartsWith(url.spec(), scope_.spec(),
                       base::CompareCase::SENSITIVE)) {
    score = base::saturated_cast<int>(scope_.spec().length());
    // A regular scope match is always better than an extended scope match.
    // Add a large constant to the score to ensure this, as the score is
    // simply the length of the scope string, which is capped at
    // url::kMaxURLChars.
    score = base::ClampAdd(score, url::kMaxURLChars);
  }

  // Note: This is considered whether or not extensions are excluded due to
  // historical reasons.
#if BUILDFLAG(IS_CHROMEOS)
  if (chromeos::features::IsUploadOfficeToCloudEnabled()) {
    score = std::max(score, ChromeOsWebAppExperiments::GetExtendedScopeScore(
                                app_id_, url.spec()));
  }
#endif  // BUILDFLAG(IS_CHROMEOS)

  if (options.exclude_scope_extensions) {
    return score;
  }

  return std::max(
      score, GetScopeExtensionsScore(app_id_, url, validated_scope_extensions_,
                                     ScoreBehavior::kMaximumScore));
}

bool WebAppScope::operator==(const WebAppScope& other) const {
  return scope_ == other.scope_ &&
         validated_scope_extensions_ == other.validated_scope_extensions_;
}

}  // namespace web_app
