// Copyright 2016 The PDFium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Original code copyright 2014 Foxit Software Inc. http://www.foxitsoftware.com

#include "core/fpdftext/cpdf_linkextract.h"

#include <wchar.h>

#include <vector>

#include "core/fpdftext/cpdf_textpage.h"
#include "core/fxcrt/fx_extension.h"
#include "core/fxcrt/fx_string.h"
#include "core/fxcrt/fx_system.h"

namespace {

// Find the end of a web link starting from offset |start| and ending at offset
// |end|. The purpose of this function is to separate url from the surrounding
// context characters, we do not intend to fully validate the url. |str|
// contains lower case characters only.
size_t FindWebLinkEnding(const WideString& str, size_t start, size_t end) {
  if (str.Contains(L'/', start)) {
    // When there is a path and query after '/', most ASCII chars are allowed.
    // We don't sanitize in this case.
    return end;
  }

  // When there is no path, it only has IP address or host name.
  // Port is optional at the end.
  if (str[start] == L'[') {
    // IPv6 reference.
    // Find the end of the reference.
    auto result = str.Find(L']', start + 1);
    if (result.has_value()) {
      end = result.value();
      if (end > start + 1) {  // Has content inside brackets.
        size_t len = str.GetLength();
        size_t off = end + 1;
        if (off < len && str[off] == L':') {
          off++;
          while (off < len && FXSYS_IsDecimalDigit(str[off])) {
            off++;
          }
          if (off > end + 2 &&
              off <= len) {  // At least one digit in port number.
            end = off - 1;   // |off| is offset of the first invalid char.
          }
        }
      }
    }
    return end;
  }

  // According to RFC1123, host name only has alphanumeric chars, hyphens,
  // and periods. Hyphen should not at the end though.
  // Non-ASCII chars are ignored during checking.
  while (end > start && str[end] < 0x80) {
    if (FXSYS_IsDecimalDigit(str[end]) ||
        (str[end] >= L'a' && str[end] <= L'z') || str[end] == L'.') {
      break;
    }
    end--;
  }
  return end;
}

// Remove characters from the end of |str|, delimited by |start| and |end|, up
// to and including |charToFind|. No-op if |charToFind| is not present. Updates
// |end| if characters were removed.
void TrimBackwardsToChar(const WideString& str,
                         wchar_t charToFind,
                         size_t start,
                         size_t* end) {
  for (size_t pos = *end; pos >= start; pos--) {
    if (str[pos] == charToFind) {
      *end = pos - 1;
      break;
    }
  }
}

// Finds opening brackets ()[]{}<> and quotes "'  before the URL delimited by
// |start| and |end| in |str|. Matches a closing bracket or quote for each
// opening character and, if present, removes everything afterwards. Returns the
// new end position for the string.
size_t TrimExternalBracketsFromWebLink(const WideString& str,
                                       size_t start,
                                       size_t end) {
  for (size_t pos = 0; pos < start; pos++) {
    if (str[pos] == '(') {
      TrimBackwardsToChar(str, ')', start, &end);
    } else if (str[pos] == '[') {
      TrimBackwardsToChar(str, ']', start, &end);
    } else if (str[pos] == '{') {
      TrimBackwardsToChar(str, '}', start, &end);
    } else if (str[pos] == '<') {
      TrimBackwardsToChar(str, '>', start, &end);
    } else if (str[pos] == '"') {
      TrimBackwardsToChar(str, '"', start, &end);
    } else if (str[pos] == '\'') {
      TrimBackwardsToChar(str, '\'', start, &end);
    }
  }
  return end;
}

}  // namespace

CPDF_LinkExtract::CPDF_LinkExtract(const CPDF_TextPage* pTextPage)
    : text_page_(pTextPage) {}

CPDF_LinkExtract::~CPDF_LinkExtract() = default;

void CPDF_LinkExtract::ExtractLinks() {
  link_array_.clear();
  size_t start = 0;
  size_t pos = 0;
  bool bAfterHyphen = false;
  bool bLineBreak = false;
  const size_t nTotalChar = text_page_->CountChars();
  const WideString page_text = text_page_->GetAllPageText();
  while (pos < nTotalChar) {
    const CPDF_TextPage::CharInfo& char_info = text_page_->GetCharInfo(pos);
    if (char_info.char_type() != CPDF_TextPage::CharType::kGenerated &&
        char_info.unicode() != L' ' && pos != nTotalChar - 1) {
      bAfterHyphen =
          (char_info.char_type() == CPDF_TextPage::CharType::kHyphen ||
           (char_info.char_type() == CPDF_TextPage::CharType::kNormal &&
            char_info.unicode() == L'-'));
      ++pos;
      continue;
    }

    size_t nCount = pos - start;
    if (pos == nTotalChar - 1) {
      ++nCount;
    } else if (bAfterHyphen &&
               (char_info.unicode() == L'\n' || char_info.unicode() == L'\r')) {
      // Handle text breaks with a hyphen to the next line.
      bLineBreak = true;
      ++pos;
      continue;
    }

    WideString strBeCheck = page_text.Substr(start, nCount);
    if (bLineBreak) {
      strBeCheck.Remove(L'\n');
      strBeCheck.Remove(L'\r');
      bLineBreak = false;
    }
    // Replace the generated code with the hyphen char.
    strBeCheck.Replace(L"\xfffe", L"-");

    if (strBeCheck.GetLength() > 5) {
      while (strBeCheck.GetLength() > 0) {
        wchar_t ch = strBeCheck.Back();
        if (ch != L')' && ch != L',' && ch != L'>' && ch != L'.') {
          break;
        }

        strBeCheck = strBeCheck.First(strBeCheck.GetLength() - 1);
        nCount--;
      }

      // Check for potential web URLs and email addresses.
      // Ftp address, file system links, data, blob etc. are not checked.
      if (nCount > 5) {
        auto maybe_link = CheckWebLink(strBeCheck);
        if (maybe_link.has_value()) {
          maybe_link.value().start_ += start;
          link_array_.push_back(maybe_link.value());
        } else if (CheckMailLink(&strBeCheck)) {
          link_array_.push_back(Link{{start, nCount}, strBeCheck});
        }
      }
    }
    start = ++pos;
  }
}

std::optional<CPDF_LinkExtract::Link> CPDF_LinkExtract::CheckWebLink(
    const WideString& strBeCheck) {
  const WideStringView kHttpScheme = L"http";
  const WideStringView kWWWAddrStart = L"www.";
  WideString str = strBeCheck;
  str.MakeLower();

  // First, try to find the scheme.
  auto start = str.Find(kHttpScheme);
  if (start.has_value()) {
    size_t off = start.value() + kHttpScheme.GetLength();  // move after "http".
    if (str.GetLength() > off + 4) {  // At least "://<char>" follows.
      if (str[off] == L's') {         // "https" scheme is accepted.
        off++;
      }
      if (str[off] == L':' && str[off + 1] == L'/' && str[off + 2] == L'/') {
        off += 3;
        const size_t end =
            FindWebLinkEnding(str, off,
                              TrimExternalBracketsFromWebLink(
                                  str, start.value(), str.GetLength() - 1));
        if (end > off) {  // Non-empty host name.
          const size_t nStart = start.value();
          const size_t nCount = end - nStart + 1;
          return Link{{nStart, nCount}, strBeCheck.Substr(nStart, nCount)};
        }
      }
    }
  }

  // When there is no scheme, try to find url starting with "www.".
  start = str.Find(kWWWAddrStart);
  if (start.has_value()) {
    size_t off = start.value() + kWWWAddrStart.GetLength();
    if (str.GetLength() > off) {
      const size_t end =
          FindWebLinkEnding(str, start.value(),
                            TrimExternalBracketsFromWebLink(
                                str, start.value(), str.GetLength() - 1));
      if (end > off) {
        const size_t nStart = start.value();
        const size_t nCount = end - nStart + 1;
        return Link{{nStart, nCount},
                    L"http://" + strBeCheck.Substr(nStart, nCount)};
      }
    }
  }

  return std::nullopt;
}

bool CPDF_LinkExtract::CheckMailLink(WideString* str) {
  auto aPos = str->Find(L'@');
  // Invalid when no '@' or when starts/ends with '@'.
  if (!aPos.has_value() || aPos.value() == 0 || aPos == str->GetLength() - 1) {
    return false;
  }

  // Check the local part.
  size_t pPos = aPos.value();  // Used to track the position of '@' or '.'.
  for (size_t i = aPos.value(); i > 0; i--) {
    wchar_t ch = (*str)[i - 1];
    if (ch == L'_' || ch == L'-' || FXSYS_iswalnum(ch)) {
      continue;
    }

    if (ch != L'.' || i == pPos || i == 1) {
      if (i == aPos.value()) {
        // There is '.' or invalid char before '@'.
        return false;
      }
      // End extracting for other invalid chars, '.' at the beginning, or
      // consecutive '.'.
      size_t removed_len = i == pPos ? i + 1 : i;
      *str = str->Last(str->GetLength() - removed_len);
      break;
    }
    // Found a valid '.'.
    pPos = i - 1;
  }

  // Check the domain name part.
  aPos = str->Find(L'@');
  if (!aPos.has_value() || aPos.value() == 0) {
    return false;
  }

  str->TrimBack(L'.');
  // At least one '.' in domain name, but not at the beginning.
  // TODO(weili): RFC5322 allows domain names to be a local name without '.'.
  // Check whether we should remove this check.
  auto ePos = str->Find(L'.', aPos.value() + 1);
  if (!ePos.has_value() || ePos.value() == aPos.value() + 1) {
    return false;
  }

  // Validate all other chars in domain name.
  size_t nLen = str->GetLength();
  pPos = 0;  // Used to track the position of '.'.
  for (size_t i = aPos.value() + 1; i < nLen; i++) {
    wchar_t wch = (*str)[i];
    if (wch == L'-' || FXSYS_iswalnum(wch)) {
      continue;
    }

    if (wch != L'.' || i == pPos + 1) {
      // Domain name should end before invalid char.
      size_t host_end = i == pPos + 1 ? i - 2 : i - 1;
      if (pPos > 0 && host_end - aPos.value() >= 3) {
        // Trim the ending invalid chars if there is at least one '.' and name.
        *str = str->First(host_end + 1);
        break;
      }
      return false;
    }
    pPos = i;
  }

  if (!str->Contains(L"mailto:")) {
    *str = L"mailto:" + *str;
  }

  return true;
}

WideString CPDF_LinkExtract::GetURL(size_t index) const {
  return index < link_array_.size() ? link_array_[index].url_ : WideString();
}

std::vector<CFX_FloatRect> CPDF_LinkExtract::GetRects(size_t index) const {
  if (index >= link_array_.size()) {
    return std::vector<CFX_FloatRect>();
  }

  return text_page_->GetRectArray(link_array_[index].start_,
                                  link_array_[index].count_);
}

std::optional<CPDF_LinkExtract::Range> CPDF_LinkExtract::GetTextRange(
    size_t index) const {
  if (index >= link_array_.size()) {
    return std::nullopt;
  }
  return link_array_[index];
}
