/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

#include "FragmentDirective.h"

#include <cstdint>

#include "BasePrincipal.h"
#include "Document.h"
#include "RangeBoundary.h"
#include "TextDirectiveCreator.h"
#include "TextDirectiveFinder.h"
#include "TextDirectiveUtil.h"
#include "mozilla/Assertions.h"
#include "mozilla/CycleCollectedUniquePtr.h"
#include "mozilla/PresShell.h"
#include "mozilla/ResultVariant.h"
#include "mozilla/dom/BrowsingContext.h"
#include "mozilla/dom/BrowsingContextGroup.h"
#include "mozilla/dom/FragmentDirectiveBinding.h"
#include "mozilla/dom/FragmentOrElement.h"
#include "mozilla/dom/Promise.h"
#include "mozilla/dom/Selection.h"
#include "mozilla/glean/DomMetrics.h"
#include "nsContentUtils.h"
#include "nsDocShell.h"
#include "nsICSSDeclaration.h"
#include "nsIFrame.h"
#include "nsINode.h"
#include "nsIURIMutator.h"
#include "nsRange.h"
#include "nsString.h"

namespace mozilla::dom {

NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(FragmentDirective, mDocument, mFinder)

NS_IMPL_CYCLE_COLLECTING_ADDREF(FragmentDirective)
NS_IMPL_CYCLE_COLLECTING_RELEASE(FragmentDirective)
NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(FragmentDirective)
  NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
  NS_INTERFACE_MAP_ENTRY(nsISupports)
NS_INTERFACE_MAP_END

FragmentDirective::FragmentDirective(Document* aDocument)
    : mDocument(aDocument) {}

FragmentDirective::~FragmentDirective() = default;

JSObject* FragmentDirective::WrapObject(JSContext* aCx,
                                        JS::Handle<JSObject*> aGivenProto) {
  return FragmentDirective_Binding::Wrap(aCx, this, aGivenProto);
}

void FragmentDirective::SetTextDirectives(
    nsTArray<TextDirective>&& aTextDirectives) {
  MOZ_ASSERT(mDocument);
  if (!aTextDirectives.IsEmpty()) {
    mFinder.reset(
        new TextDirectiveFinder(mDocument, std::move(aTextDirectives)));
  } else {
    mFinder = nullptr;
  }
}

void FragmentDirective::ClearUninvokedDirectives() { mFinder = nullptr; }
bool FragmentDirective::HasUninvokedDirectives() const { return !!mFinder; };

bool FragmentDirective::ParseAndRemoveFragmentDirectiveFromFragmentString(
    nsCString& aFragment, nsTArray<TextDirective>* aTextDirectives,
    nsIURI* aURI) {
  auto uri = TextDirectiveUtil::ShouldLog() && aURI ? aURI->GetSpecOrDefault()
                                                    : nsCString();
  if (aFragment.IsEmpty()) {
    TEXT_FRAGMENT_LOG("URL '{}' has no fragment.", uri);
    return false;
  }
  TEXT_FRAGMENT_LOG(
      "Trying to extract a fragment directive from fragment '{}' of URL '{}'.",
      aFragment, uri);
  ParsedFragmentDirectiveResult fragmentDirective;
  const bool hasRemovedFragmentDirective =
      StaticPrefs::dom_text_fragments_enabled() &&
      parse_fragment_directive(&aFragment, &fragmentDirective);
  if (hasRemovedFragmentDirective) {
    TEXT_FRAGMENT_LOG(
        "Found a fragment directive '{}', which was removed from the fragment. "
        "New fragment is '{}'.",
        fragmentDirective.fragment_directive,
        fragmentDirective.hash_without_fragment_directive);
    if (TextDirectiveUtil::ShouldLog()) {
      if (fragmentDirective.text_directives.IsEmpty()) {
        TEXT_FRAGMENT_LOG(
            "Found no valid text directives in fragment directive '{}'.",
            fragmentDirective.fragment_directive);
      } else {
        TEXT_FRAGMENT_LOG(
            "Found {} valid text directives in fragment directive '{}':",
            fragmentDirective.text_directives.Length(),
            fragmentDirective.fragment_directive);
        for (size_t index = 0;
             index < fragmentDirective.text_directives.Length(); ++index) {
          const auto& textDirective = fragmentDirective.text_directives[index];
          TEXT_FRAGMENT_LOG(" [{}]: {}", index, ToString(textDirective));
        }
      }
    }
    aFragment = fragmentDirective.hash_without_fragment_directive;
    if (aTextDirectives) {
      aTextDirectives->SwapElements(fragmentDirective.text_directives);
    }
  } else {
    TEXT_FRAGMENT_LOG(
        "Fragment '{}' of URL '{}' did not contain a fragment directive.",
        aFragment, uri);
  }
  return hasRemovedFragmentDirective;
}

void FragmentDirective::ParseAndRemoveFragmentDirectiveFromFragment(
    nsCOMPtr<nsIURI>& aURI, nsTArray<TextDirective>* aTextDirectives) {
  if (!aURI || !StaticPrefs::dom_text_fragments_enabled()) {
    return;
  }
  bool hasRef = false;
  aURI->GetHasRef(&hasRef);

  nsAutoCString hash;
  aURI->GetRef(hash);
  if (!hasRef || hash.IsEmpty()) {
    TEXT_FRAGMENT_LOG("URL '{}' has no fragment. Exiting.",
                      aURI->GetSpecOrDefault());
  }

  const bool hasRemovedFragmentDirective =
      ParseAndRemoveFragmentDirectiveFromFragmentString(hash, aTextDirectives,
                                                        aURI);
  if (!hasRemovedFragmentDirective) {
    return;
  }
  (void)NS_MutateURI(aURI).SetRef(hash).Finalize(aURI);
  TEXT_FRAGMENT_LOG("Updated hash of the URL. New URL: {}",
                    aURI->GetSpecOrDefault());
}

nsTArray<RefPtr<nsRange>> FragmentDirective::FindTextFragmentsInDocument() {
  MOZ_ASSERT(mDocument);
  if (!mFinder) {
    auto uri = TextDirectiveUtil::ShouldLog() && mDocument->GetDocumentURI()
                   ? mDocument->GetDocumentURI()->GetSpecOrDefault()
                   : nsCString();
    TEXT_FRAGMENT_LOG("No uninvoked text directives in document '{}'. Exiting.",
                      uri);
    return {};
  }
  RefPtr doc = mDocument;
  doc->FlushPendingNotifications(FlushType::Layout);
  if (!mFinder) {
    return {};
  }
  auto textDirectives = mFinder->FindTextDirectivesInDocument();
  if (!mFinder->HasUninvokedDirectives()) {
    mFinder = nullptr;
  }
  return textDirectives;
}

/* static */ nsresult FragmentDirective::GetSpecIgnoringFragmentDirective(
    nsCOMPtr<nsIURI>& aURI, nsACString& aSpecIgnoringFragmentDirective) {
  bool hasRef = false;
  if (aURI->GetHasRef(&hasRef); !hasRef) {
    return aURI->GetSpec(aSpecIgnoringFragmentDirective);
  }

  nsAutoCString ref;
  nsresult rv = aURI->GetRef(ref);
  if (NS_FAILED(rv)) {
    return rv;
  }

  rv = aURI->GetSpecIgnoringRef(aSpecIgnoringFragmentDirective);
  if (NS_FAILED(rv)) {
    return rv;
  }

  ParseAndRemoveFragmentDirectiveFromFragmentString(ref);

  if (!ref.IsEmpty()) {
    aSpecIgnoringFragmentDirective.Append('#');
    aSpecIgnoringFragmentDirective.Append(ref);
  }

  return NS_OK;
}

bool FragmentDirective::IsTextDirectiveAllowedToBeScrolledTo() {
  // This method follows
  // https://wicg.github.io/scroll-to-text-fragment/#check-if-a-text-directive-can-be-scrolled
  // However, there are some spec issues
  // (https://github.com/WICG/scroll-to-text-fragment/issues/240).
  // The web-platform tests currently seem more up-to-date. Therefore,
  // this method is adapted slightly to make sure all tests pass.
  // Comments are added to explain changes.

  MOZ_ASSERT(mDocument);
  auto uri = TextDirectiveUtil::ShouldLog() && mDocument->GetDocumentURI()
                 ? mDocument->GetDocumentURI()->GetSpecOrDefault()
                 : nsCString();
  TEXT_FRAGMENT_LOG(
      "Trying to find out if the load of URL '{}' is allowed to scroll to the "
      "text fragment",
      uri);
  // It seems the spec does not cover same-document navigation in particular,
  // or Gecko needs to deal with this in a different way due to the
  // implementation not following the spec step-by-step.
  // Therefore, the following algorithm needs some adaptions to deal with
  // same-document navigations correctly.

  nsCOMPtr<nsILoadInfo> loadInfo =
      mDocument->GetChannel() ? mDocument->GetChannel()->LoadInfo() : nullptr;
  const bool isSameDocumentNavigation =
      loadInfo && loadInfo->GetIsSameDocumentNavigation();

  TEXT_FRAGMENT_LOG("Current load is{} a same-document navigation.",
                    isSameDocumentNavigation ? "" : " not");

  // 1. If document's pending text directives field is null or empty, return
  // false.
  // ---
  // we don't store the *pending* text directives in this class, only the
  // *uninvoked* text directives (uninvoked = `TextDirective`, pending =
  // `nsRange`).
  // Uninvoked text directives are typically already processed into pending text
  // directives when this code is called. Pending text directives are handled by
  // the caller when this code runs; therefore, the caller should decide if this
  // method should be called or not.

  // 2. Let is user involved be true if: document's text directive user
  // activation is true, or user involvement is one of "activation" or "browser
  // UI"; false otherwise.
  // 3. Set document's text directive user activation to false.
  const bool textDirectiveUserActivation =
      mDocument->ConsumeTextDirectiveUserActivation();
  TEXT_FRAGMENT_LOG(
      "Consumed Document's TextDirectiveUserActivation flag (value={})",
      textDirectiveUserActivation ? "true" : "false");

  // 4. If document's content type is not a text directive allowing MIME type,
  // return false.
  const bool isAllowedMIMEType = [doc = this->mDocument, func = __FUNCTION__] {
    nsAutoString contentType;
    doc->GetContentType(contentType);
    TEXT_FRAGMENT_LOG_FN("Got document MIME type: {}", func,
                         NS_ConvertUTF16toUTF8(contentType));
    return contentType == u"text/html" || contentType == u"text/plain";
  }();

  if (!isAllowedMIMEType) {
    TEXT_FRAGMENT_LOG("Invalid document MIME type. Scrolling not allowed.");
    return false;
  }

  // 5. If user involvement is "browser UI", return true.
  //
  // If a navigation originates from browser UI, it's always ok to allow it
  // since it'll be user triggered and the page/script isn't providing the text
  // snippet.
  //
  // Note: The intent in this item is to distinguish cases where the app/page is
  // able to control the URL from those that are fully under the user's
  // control. In the former we want to prevent scrolling of the text fragment
  // unless the destination is loaded in a separate browsing context group (so
  // that the source cannot both control the text snippet and observe
  // side-effects in the navigation). There are some cases where "browser UI"
  // may be a grey area in this regard. E.g. an "open in new window" context
  // menu item when right clicking on a link.
  //
  // See sec-fetch-site [0] for a related discussion on how this applies.
  // [0] https://w3c.github.io/webappsec-fetch-metadata/#directly-user-initiated
  // ---
  // Gecko does not implement user involvement as defined in the spec.
  // However, if the triggering principal is the system principal, the load
  // has been triggered from browser chrome. This should be good enough for now.
  auto* triggeringPrincipal =
      loadInfo ? loadInfo->TriggeringPrincipal() : nullptr;
  const bool isTriggeredFromBrowserUI =
      triggeringPrincipal && triggeringPrincipal->IsSystemPrincipal();

  if (isTriggeredFromBrowserUI) {
    TEXT_FRAGMENT_LOG(
        "The load is triggered from browser UI. Scrolling allowed.");
    return true;
  }
  TEXT_FRAGMENT_LOG("The load is not triggered from browser UI.");
  // 6. If is user involved is false, return false.
  // ---
  // same-document navigation is not mentioned in the spec. However, we run this
  // code also in same-document navigation cases.
  // Same-document navigation is allowed even without any user interaction.
  if (!textDirectiveUserActivation && !isSameDocumentNavigation) {
    TEXT_FRAGMENT_LOG(
        "User involvement is false and not same-document navigation. Scrolling "
        "not allowed.");
    return false;
  }
  // 7. If document's node navigable has a parent, return false.
  // ---
  // this is extended to ignore this rule if this is a same-document navigation
  // in an iframe, which is allowed when the document's origin matches the
  // initiator's origin (which is checked in step 8).
  nsDocShell* docShell = nsDocShell::Cast(mDocument->GetDocShell());
  if (!isSameDocumentNavigation &&
      (!docShell || !docShell->GetIsTopLevelContentDocShell())) {
    TEXT_FRAGMENT_LOG(
        "Document's node navigable has a parent and this is not a "
        "same-document navigation. Scrolling not allowed.");
    return false;
  }
  // 8. If initiator origin is non-null and document's origin is same origin
  // with initiator origin, return true.
  const bool isSameOrigin = [doc = this->mDocument, triggeringPrincipal] {
    auto* docPrincipal = doc->GetPrincipal();
    return triggeringPrincipal && docPrincipal &&
           docPrincipal->Equals(triggeringPrincipal);
  }();

  if (isSameOrigin) {
    TEXT_FRAGMENT_LOG("Same origin. Scrolling allowed.");
    return true;
  }
  TEXT_FRAGMENT_LOG("Not same origin.");

  // 9. If document's browsing context's group's browsing context set has length
  // 1, return true.
  //
  // i.e. Only allow navigation from a cross-origin element/script if the
  // document is loaded in a noopener context. That is, a new top level browsing
  // context group to which the navigator does not have script access and which
  // can be placed into a separate process.
  if (BrowsingContextGroup* group =
          mDocument->GetBrowsingContext()
              ? mDocument->GetBrowsingContext()->Group()
              : nullptr) {
    const bool isNoOpenerContext = group->Toplevels().Length() == 1;
    if (!isNoOpenerContext) {
      TEXT_FRAGMENT_LOG(
          "Cross-origin + noopener=false. Scrolling not allowed.");
    }
    return isNoOpenerContext;
  }

  // 10.Otherwise, return false.
  TEXT_FRAGMENT_LOG("Scrolling not allowed.");
  return false;
}

void FragmentDirective::HighlightTextDirectives(
    const nsTArray<RefPtr<nsRange>>& aTextDirectiveRanges) {
  MOZ_ASSERT(mDocument);
  if (!StaticPrefs::dom_text_fragments_enabled()) {
    return;
  }
  auto uri = TextDirectiveUtil::ShouldLog() && mDocument->GetDocumentURI()
                 ? mDocument->GetDocumentURI()->GetSpecOrDefault()
                 : nsCString();
  if (aTextDirectiveRanges.IsEmpty()) {
    TEXT_FRAGMENT_LOG(
        "No text directive ranges to highlight for document '{}'. Exiting.",
        uri);
    return;
  }

  TEXT_FRAGMENT_LOG(
      "Highlighting text directives for document '{}' ({} ranges).", uri,
      aTextDirectiveRanges.Length());

  const RefPtr<Selection> targetTextSelection =
      [doc = this->mDocument]() -> Selection* {
    if (auto* presShell = doc->GetPresShell()) {
      return presShell->GetCurrentSelection(SelectionType::eTargetText);
    }
    return nullptr;
  }();
  if (!targetTextSelection) {
    return;
  }
  for (const RefPtr<nsRange>& range : aTextDirectiveRanges) {
    // Script won't be able to manipulate `aTextDirectiveRanges`,
    // therefore we can mark `range` as known live.
    targetTextSelection->AddRangeAndSelectFramesAndNotifyListeners(
        MOZ_KnownLive(*range), IgnoreErrors());
  }
  // AddRangeAndSelectFramesAndNotifyListeners sets the selection's anchor to
  // each newly added range, so after the loop the anchor points to the last
  // range. The selection stores ranges in document order, which may differ
  // from directive (URL) order. Find the first directive's range and set the
  // anchor to it so that ScrollSelectionIntoView scrolls to the correct one.
  const nsRange* firstDirectiveRange = aTextDirectiveRanges[0];
  for (uint32_t rangeIndex : IntegerRange(targetTextSelection->RangeCount())) {
    if (targetTextSelection->GetRangeAt(rangeIndex) == firstDirectiveRange) {
      targetTextSelection->SetAnchorFocusRange(rangeIndex);
      break;
    }
  }
}

void FragmentDirective::GetTextDirectiveRanges(
    nsTArray<RefPtr<nsRange>>& aRanges) const {
  if (!StaticPrefs::dom_text_fragments_enabled()) {
    return;
  }
  auto* presShell = mDocument ? mDocument->GetPresShell() : nullptr;
  if (!presShell) {
    return;
  }
  RefPtr<Selection> targetTextSelection =
      presShell->GetCurrentSelection(SelectionType::eTargetText);
  if (!targetTextSelection) {
    return;
  }

  aRanges.Clear();
  for (uint32_t rangeIndex = 0; rangeIndex < targetTextSelection->RangeCount();
       ++rangeIndex) {
    nsRange* range = targetTextSelection->GetRangeAt(rangeIndex);
    MOZ_ASSERT(range);
    aRanges.AppendElement(range);
  }
}
void FragmentDirective::RemoveAllTextDirectives(ErrorResult& aRv) {
  if (!StaticPrefs::dom_text_fragments_enabled()) {
    return;
  }
  auto* presShell = mDocument ? mDocument->GetPresShell() : nullptr;
  if (!presShell) {
    return;
  }
  RefPtr<Selection> targetTextSelection =
      presShell->GetCurrentSelection(SelectionType::eTargetText);
  if (!targetTextSelection) {
    return;
  }
  targetTextSelection->RemoveAllRanges(aRv);
}

already_AddRefed<Promise> FragmentDirective::CreateTextDirectiveForRanges(
    const Sequence<OwningNonNull<nsRange>>& aRanges) {
  RefPtr<Promise> resultPromise =
      Promise::Create(mDocument->GetRelevantGlobal(), IgnoreErrors());
  if (!resultPromise) {
    return nullptr;
  }
  if (!StaticPrefs::dom_text_fragments_enabled()) {
    TEXT_FRAGMENT_LOG("Creating text fragments is disabled.");
    resultPromise->MaybeResolve(JS::NullHandleValue);
    return resultPromise.forget();
  }
  if (aRanges.IsEmpty()) {
    TEXT_FRAGMENT_LOG("No ranges. Nothing to do here...");
    resultPromise->MaybeResolve(JS::NullHandleValue);
    return resultPromise.forget();
  }
  TEXT_FRAGMENT_LOG("Creating text directive for {} ranges.", aRanges.Length());

  nsTArray<nsCString> textDirectives;
  textDirectives.SetCapacity(aRanges.Length());

  const TimeStamp start = TimeStamp::Now();
  RefPtr<TimeoutWatchdog> watchdog = new TimeoutWatchdog();
  uint32_t rangeIndex = 0;
  for (const auto& range : aRanges) {
    ++rangeIndex;

    if (range->Collapsed()) {
      TEXT_FRAGMENT_LOG("Skipping collapsed range {}.", rangeIndex);
      continue;
    }
    Result<nsCString, ErrorResult> maybeTextDirective =
        TextDirectiveCreator::CreateTextDirectiveFromRange(mDocument, range,
                                                           watchdog);
    if (MOZ_UNLIKELY(maybeTextDirective.isErr())) {
      TEXT_FRAGMENT_LOG("Failed to create text directive for range {}.",
                        rangeIndex);
      resultPromise->MaybeReject(maybeTextDirective.unwrapErr());
      return resultPromise.forget();
    }
    nsCString textDirective = maybeTextDirective.unwrap();
    if (textDirective.IsEmpty() || textDirective.IsVoid()) {
      TEXT_FRAGMENT_LOG("Skipping empty text directive for range {}.",
                        rangeIndex);
      continue;
    }
    textDirectives.AppendElement(std::move(textDirective));
    TEXT_FRAGMENT_LOG("Created text directive for range {}: {}", rangeIndex,
                      textDirectives.LastElement());
  }

  if (watchdog->IsDone()) {
    TEXT_FRAGMENT_LOG("Hitting timeout while creating text directives.");
    resultPromise->MaybeResolve(JS::NullHandleValue);
  } else if (textDirectives.IsEmpty()) {
    TEXT_FRAGMENT_LOG("No text directives created.");
    mDocument->SetUseCounter(eUseCounter_custom_TextDirectiveNotCreated);
    resultPromise->MaybeResolve(JS::NullHandleValue);
  } else {
    TEXT_FRAGMENT_LOG("Created {} text directives in total.",
                      textDirectives.Length());
    nsAutoCString textDirectivesString;
    StringJoinAppend(textDirectivesString, "&"_ns, textDirectives);
    TEXT_FRAGMENT_LOG("Created text directive string: '{}'.",
                      textDirectivesString);
    resultPromise->MaybeResolve(textDirectivesString);
  }

  glean::dom_textfragment::create_directive.AccumulateRawDuration(
      TimeStamp::Now() - start);

  return resultPromise.forget();
}

}  // namespace mozilla::dom
