# frozen_string_literal: true

require "nokogiri"
require "erb"

module PrettyText
  DANGEROUS_BIDI_CHARACTERS = [
    "\u202A",
    "\u202B",
    "\u202C",
    "\u202D",
    "\u202E",
    "\u2066",
    "\u2067",
    "\u2068",
    "\u2069",
  ].freeze
  DANGEROUS_BIDI_REGEXP = Regexp.new(DANGEROUS_BIDI_CHARACTERS.join("|")).freeze

  BLOCKED_HOTLINKED_SRC_ATTR = "data-blocked-hotlinked-src"
  BLOCKED_HOTLINKED_SRCSET_ATTR = "data-blocked-hotlinked-srcset"

  VIMEO_PLAYER_HOST = "player.vimeo.com"
  VIMEO_PLAYER_PATH = %r{\A/video/(?<id>\d+)/?\z}
  VIMEO_UNLISTED_HASH = /\A[a-zA-Z0-9]+\z/

  @mutex = Mutex.new
  @ctx_init = Mutex.new

  def self.app_root
    Rails.root
  end

  def self.apply_es6_file(ctx:, path:, module_name:)
    source = File.read(path)
    processor = AssetProcessor.new
    transpiled = processor.perform(source, nil, module_name)
    ctx.eval(transpiled, filename: module_name)
  end

  # The only modules from the `discourse` package which may be bundled into the
  # server-side renderer. Additions must not transitively depend on
  # browser-only APIs.
  BUNDLED_DISCOURSE_MODULES = %w[
    deprecation-workflow
    lib/avatar-utils
    lib/case-converter
    lib/escape
    lib/get-url
    lib/object
    loader
    static/markdown-it/features
  ]

  CORE_BUNDLE =
    PrecompiledBundle.new(
      dir: "tmp/pretty-text-processor",
      filename_prefix: "pretty-text",
      dependency_globs:
        %w[
          node_modules/.pnpm/lock.yaml
          frontend/pretty-text-processor/**/*.{js,mjs,cjs,json}
          frontend/pretty-text/addon/**/*.js
          frontend/discourse-markdown-it/src/**/*.js
        ] + BUNDLED_DISCOURSE_MODULES.map { "frontend/discourse/app/#{it}.js" },
    ) do
      Discourse::Utils.execute_command(
        "pnpm",
        "-C=frontend/pretty-text-processor",
        "node",
        "build.mjs",
        "--discourse-modules=#{BUNDLED_DISCOURSE_MODULES.join(",")}",
        chdir: Rails.root.to_s,
      )
    end

  def self.load_or_build_core_bundle
    CORE_BUNDLE.load_or_build
  end

  def self.core_bundle_source
    raise <<~MSG.squish if Rails.env.production? && !CORE_BUNDLE.precompiled?
        PrettyText core bundle has not been precompiled (#{CORE_BUNDLE.path}).
        Run `bin/rake assets:precompile` before booting.
      MSG

    CORE_BUNDLE.load_or_build
  end

  def self.create_es6_context
    ctx = MiniRacer::Context.new(timeout: 25_000, ensure_gc_after_idle: 2000)

    ctx.eval("window = globalThis; window.devicePixelRatio = 2;") # hack to make code think stuff is retina

    ctx.attach(
      "rails.logger.info",
      proc do |err|
        Rails.logger.info(err.to_s)
        nil
      end,
    )
    ctx.attach(
      "rails.logger.warn",
      proc do |err|
        Rails.logger.warn(err.to_s)
        nil
      end,
    )
    ctx.attach(
      "rails.logger.error",
      proc do |err|
        Rails.logger.error(err.to_s)
        nil
      end,
    )
    ctx.eval <<~JS
      console = {
        prefix: "[PrettyText] ",
        log: function(...args){ rails.logger.info(console.prefix + args.join(" ")); },
        warn: function(...args){ rails.logger.warn(console.prefix + args.join(" ")); },
        error: function(...args){ rails.logger.error(console.prefix + args.join(" ")); }
      }
    JS

    ctx.eval("__PRETTY_TEXT = true")

    # `__Ruby` must exist before the bundle runs — it captures it at init.
    PrettyText::Helpers.instance_methods.each do |method|
      ctx.attach("__Ruby.#{method}", PrettyText::Helpers.method(method))
    end

    ctx.eval(core_bundle_source, filename: "pretty-text.js")
    ctx.call("__PrettyText.setUnicode", JSON.parse(Emoji.unicode_replacements_json))

    Discourse.plugins.each do |plugin|
      Dir
        .glob("#{plugin.directory}/assets/javascripts/**/discourse-markdown/**/*.{js,js.es6}")
        .filter { |a| File.file?(a) }
        .each do |f|
          module_name =
            f.sub(%r{\A.+assets/javascripts/}, "discourse/plugins/#{plugin.name}/").sub(
              /\.js(\.es6)?\z/,
              "",
            )
          apply_es6_file(ctx: ctx, path: f, module_name: module_name)
        end
    end

    DiscoursePluginRegistry.vendored_core_pretty_text.each { |vpt| ctx.eval(File.read(vpt)) }

    DiscoursePluginRegistry.vendored_pretty_text.each { |vpt| ctx.eval(File.read(vpt)) }

    ctx.low_memory_notification # GC to free up memory used during init

    ctx
  end

  def self.v8
    return @ctx if @ctx

    # ensure we only init one of these
    @ctx_init.synchronize do
      return @ctx if @ctx
      @ctx = create_es6_context
    end

    @ctx
  end

  def self.reset_translations
    @mutex.synchronize do
      v8.call("__PrettyText.resetTranslations")
      v8.low_memory_notification if GlobalSetting.mini_racer_single_threaded
    end
  end

  def self.reset_context
    @ctx_init.synchronize do
      @ctx&.dispose
      @ctx = nil
    end
  end

  # Acceptable options:
  #
  #  disable_emojis    - Disables the emoji markdown engine.
  #  features          - A hash where the key is the markdown feature name and the value is a boolean to enable/disable the markdown feature.
  #                      The hash is merged into the default features set in pretty-text.js which can be used to add new features or disable existing features.
  #  features_override - An array of markdown feature names to override the default markdown feature set. Currently used by plugins to customize what features should be enabled
  #                      when rendering markdown.
  #  markdown_it_rules - An array of markdown rule names which will be applied to the markdown-it engine. Currently used by plugins to customize what markdown-it rules should be
  #                      enabled when rendering markdown.
  #  topic_id          - Topic id for the post being cooked.
  #  post_id           - Post id for the post being cooked.
  #  user_id           - User id for the post being cooked.
  #  force_quote_link  - Always create the link to the quoted topic for [quote] bbcode. Normally this only happens
  #                      if the topic_id provided is different from the [quote topic:X].
  #  hashtag_context   - Defaults to "topic-composer" if not supplied. Controls the order of #hashtag lookup results
  #                      based on registered hashtag contexts from the `#register_hashtag_search_param` plugin API
  #                      method.
  def self.markdown(text, opts = {})
    # we use the exact same markdown converter as the client
    # TODO: use the same extensions on both client and server (in particular the template for mentions)
    text = text || ""

    custom_emoji = {}
    Emoji.custom.map { |e| custom_emoji[e.name] = e.cdn_url }

    allowed_iframes =
      DiscoursePluginRegistry.apply_modifier(
        :pretty_text_allowed_iframes,
        SiteSetting.allowed_iframes.split("|"),
      )

    opts[:hashtag_context] ||= "topic-composer"

    # Any addition here must also be added to buildOptions in discourse-markdown-it.
    opt_input = {
      siteSettings: SiteSetting.client_settings_hash,
      allowedIframes: allowed_iframes,
      paths: paths,
      customEmoji: custom_emoji,
      customEmojiTranslation: Plugin::CustomEmoji.translations,
      emojiDenyList: Emoji.denied,
      censoredRegexp: WordWatcher.serialized_regexps_for_action(:censor),
      watchedWordsReplace: WordWatcher.regexps_for_action(:replace),
      watchedWordsLink: WordWatcher.regexps_for_action(:link),
      additionalOptions: Site.markdown_additional_options,
      avatar_sizes: SiteSetting.avatar_sizes,
      hashtagTypesInPriorityOrder:
        HashtagAutocompleteService.ordered_types_for_context(opts[:hashtag_context]),
      hashtagIcons: HashtagAutocompleteService.data_source_icon_map,
    }

    opt_input[:disableEmojis] = true if opts[:disable_emojis]
    opt_input[:features] = opts[:features] if opts[:features]
    opt_input[:featuresOverride] = opts[:features_override] if opts[:features_override]
    opt_input[:markdownItRules] = opts[:markdown_it_rules] if opts[:markdown_it_rules]
    opt_input[:topicId] = opts[:topic_id].to_i if opts[:topic_id]
    opt_input[:postId] = opts[:post_id].to_i if opts[:post_id]
    opt_input[:forceQuoteLink] = opts[:force_quote_link] if opts[:force_quote_link]
    opt_input[:userId] = opts[:user_id].to_i if opts[:user_id]
    # Be careful disabling sanitization. We allow for custom emails
    opt_input[:disableSanitizer] = true if opts[:sanitize] == false

    protect do
      DiscourseEvent.trigger(:markdown_context, v8)
      v8.call("__PrettyText.cook", text, opt_input)
    end
  end

  def self.paths
    paths = { baseUri: Discourse.base_path, CDN: Rails.configuration.action_controller.asset_host }

    if SiteSetting.Upload.enable_s3_uploads
      paths[:S3CDN] = SiteSetting.Upload.s3_cdn_url if SiteSetting.Upload.s3_cdn_url.present?
      paths[:S3BaseUrl] = Discourse.store.absolute_base_url
    end

    paths
  end

  def self.paths_json
    paths.to_json
  end

  # leaving this here, cause it invokes v8, don't want to implement twice
  def self.avatar_img(avatar_template, size)
    protect do
      v8.call("__PrettyText.avatarImg", avatar_template, size, paths, SiteSetting.avatar_sizes)
    end
  end

  def self.sanitize(html, opts = {})
    protect { v8.call("__PrettyText.sanitize", html.to_s, opts) }
  end

  def self.unescape_emoji(title)
    return title unless SiteSetting.enable_emoji? && title

    options = {
      paths: paths,
      emojiSet: SiteSetting.emoji_set,
      emojiCDNUrl: SiteSetting.external_emoji_url.presence || "",
      customEmoji: Emoji.custom.map { |e| [e.name, e.cdn_url] }.to_h,
      enableEmojiShortcuts: SiteSetting.enable_emoji_shortcuts,
      inlineEmoji: SiteSetting.enable_inline_emoji_translation,
    }

    protect { v8.call("__PrettyText.performEmojiUnescape", title, options) }
  end

  def self.escape_emoji(title)
    return unless title

    options = {
      emojiShortcuts: SiteSetting.enable_emoji && SiteSetting.enable_emoji_shortcuts,
      inlineEmoji: SiteSetting.enable_inline_emoji_translation,
    }

    protect { v8.call("__PrettyText.performEmojiEscape", title, options) }
  end

  def self.cook(raw, opts = {})
    options = opts.dup
    working_text = raw.dup

    html = markdown(working_text, options)

    cleanup(html, opts)
  end

  def self.strip_hidden_unicode_bidirectional_characters(doc)
    return if !DANGEROUS_BIDI_REGEXP.match?(doc.content)

    doc
      .css("code,pre")
      .each do |code_tag|
        next if !DANGEROUS_BIDI_REGEXP.match?(code_tag.content)

        DANGEROUS_BIDI_CHARACTERS.each do |bidi|
          next if !code_tag.content.include?(bidi)

          formatted = "&lt;U+#{bidi.ord.to_s(16).upcase}&gt;"
          code_tag.inner_html =
            code_tag.inner_html.gsub(
              bidi,
              "<span class=\"bidi-warning\" title=\"#{I18n.t("post.hidden_bidi_character")}\">#{formatted}</span>",
            )
        end
      end
  end

  def self.sanitize_hotlinked_media(doc)
    return if !SiteSetting.block_hotlinked_media

    allowed_pattern = allowed_src_pattern

    doc
      .css("img[src], source[src], source[srcset], track[src], div[data-video-src]")
      .each do |el|
        if el["src"] && !el["src"].match?(allowed_pattern)
          el[PrettyText::BLOCKED_HOTLINKED_SRC_ATTR] = el.delete("src")
        end
        if el["data-video-src"] && !el["data-video-src"].match?(allowed_pattern)
          el[PrettyText::BLOCKED_HOTLINKED_SRC_ATTR] = el["data-video-src"]
        end

        if el["srcset"]
          srcs = el["srcset"].split(",").map { |e| e.split(" ", 2)[0].presence }
          if srcs.any? { |src| !src.match?(allowed_pattern) }
            el[PrettyText::BLOCKED_HOTLINKED_SRCSET_ATTR] = el.delete("srcset")
          end
        end
      end
  end

  def self.add_rel_attributes_to_user_content(doc, add_nofollow)
    allowlist = []

    domains = SiteSetting.exclude_rel_nofollow_domains
    allowlist = domains.split("|") if domains.present?

    site_uri = nil
    doc
      .css("a")
      .each do |l|
        href = l["href"].to_s
        l["rel"] = "noopener" if l["target"] == "_blank"

        begin
          uri = URI(UrlHelper.encode_component(href))
          site_uri ||= URI(Discourse.base_url)

          same_domain =
            !uri.host.present? || uri.host == site_uri.host ||
              uri.host.ends_with?(".#{site_uri.host}") ||
              allowlist.any? { |u| uri.host == u || uri.host.ends_with?(".#{u}") }

          l["rel"] = "noopener nofollow ugc" if add_nofollow && !same_domain
        rescue URI::Error
          # add a nofollow anyway
          l["rel"] = "noopener nofollow ugc"
        end
      end
  end

  class DetectedLink < Struct.new(:url, :is_quote)
  end

  def self.extract_links(html)
    links = []
    doc = Nokogiri::HTML5.fragment(html)

    # extract onebox links
    doc
      .css("aside.onebox[data-onebox-src]")
      .each { |onebox| links << DetectedLink.new(onebox["data-onebox-src"], false) }

    # remove href inside quotes & oneboxes & elided part
    doc.css("aside.quote a, aside.onebox a, .elided a").remove

    # remove hotlinked images
    doc.css("a.lightbox > img, a.onebox > img").each { |img| img.parent.remove }

    # extract all links
    doc
      .css("a")
      .each do |a|
        links << DetectedLink.new(a["href"], false) if a["href"].present? && a["href"][0] != "#"
      end

    # extract quotes
    doc
      .css("aside.quote[data-topic]")
      .each do |aside|
        if aside["data-topic"].present?
          url = +"/t/#{aside["data-topic"]}"
          url << "/#{aside["data-post"]}" if aside["data-post"].present?
          links << DetectedLink.new(url, true)
        end
      end

    # extract Youtube links
    doc
      .css("div[data-video-id]")
      .each do |div|
        if div["data-video-id"].present? && div["data-provider-name"].present?
          base_url =
            case div["data-provider-name"]
            when "youtube"
              "https://www.youtube.com/watch?v="
            when "vimeo"
              "https://vimeo.com/"
            when "tiktok"
              "https://m.tiktok.com/v/"
            end
          links << DetectedLink.new(base_url + div["data-video-id"], false)
        end
      end

    links
  end

  def self.add_video_placeholder_image(doc)
    doc
      .css(".video-placeholder-container")
      .each do |video|
        video_src = video["data-video-src"]
        next if video_src == "/404" || video_src.nil?
        video_sha1 = File.basename(video_src, File.extname(video_src))
        thumbnail =
          Upload.where("original_filename LIKE ?", "#{Upload.sanitize_sql_like(video_sha1)}.%").last
        if thumbnail
          video["data-thumbnail-src"] = UrlHelper.absolute(
            GlobalPath.upload_cdn_path(thumbnail.url),
          )
          video[
            "data-video-base62-sha1"
          ] = "#{Upload.base62_sha1(video_sha1)}#{File.extname(video_src)}"
        end
      end
  end

  def self.extract_mentions(cooked)
    mentions =
      cooked
        .css(".mention, .mention-group")
        .filter_map do |e|
          if (name = e.inner_text)
            User.normalize_username(name[1..-1]) if name[0] == "@"
          end
        end

    mentions =
      DiscoursePluginRegistry.apply_modifier(:pretty_text_extract_mentions, mentions, cooked)

    mentions.compact!
    mentions.uniq!
    mentions
  end

  def self.excerpt(html, max_length, options = {})
    return "" if html.blank?

    # TODO: properly fix this HACK in ExcerptParser without introducing XSS
    doc =
      begin
        Nokogiri::HTML5.fragment(html)
      rescue ArgumentError
        return ""
      end
    DiscourseEvent.trigger(:reduce_excerpt, doc, options)
    strip_image_wrapping(doc)
    strip_oneboxed_media(doc)
    convert_hashtag_links_to_plaintext(doc) if options[:plain_hashtags]

    html = doc.to_html
    ExcerptParser.get_excerpt(html, max_length, options)
  end

  def self.convert_hashtag_links_to_plaintext(doc)
    doc
      .css("a.hashtag-cooked")
      .each { |hashtag| hashtag.replace(doc.document.create_text_node("##{hashtag["data-slug"]}")) }
  end

  def self.strip_links(string)
    return string if string.blank?

    # If the user is not basic, strip links from their bio
    fragment = Nokogiri::HTML5.fragment(string)
    fragment.css("a").each { |a| a.replace(a.inner_html) }
    fragment.to_html
  end

  def self.make_all_links_absolute(doc)
    doc
      .css("a[href]")
      .each do |a|
        href = a["href"].to_s
        next if href.blank?
        next if href.start_with?("mailto:")
        next if href.start_with?(Discourse.base_url)
        next if URI(href).host.present?

        a["href"] = (
          if href.start_with?(Discourse.base_path)
            "#{Discourse.base_url_no_prefix}#{href}"
          else
            "#{Discourse.base_url}#{href}"
          end
        )
      rescue URI::Error
        # leave it
      end
  end

  def self.strip_image_wrapping(doc)
    doc.css(".lightbox-wrapper .meta").remove
  end

  def self.strip_oneboxed_media(doc)
    doc.css("audio").remove
    doc.css(".video-onebox,video").remove
  end

  def self.convert_vimeo_iframes(doc)
    doc
      .css("iframe[src*='player.vimeo.com']")
      .each do |iframe|
        vimeo_url = vimeo_url_from_iframe(iframe)
        next if vimeo_url.blank?

        paragraph = doc.document.create_element("p")
        link = doc.document.create_element("a")
        link["href"] = vimeo_url
        link.content = vimeo_url
        paragraph.add_child(link)
        iframe.replace(paragraph)
      end
  end

  def self.vimeo_url_from_iframe(iframe)
    if iframe["data-original-href"].present?
      url = UrlHelper.normalized_encode(iframe["data-original-href"])
      scheme = UrlHelper.relaxed_parse(url)&.scheme
      return %w[http https].include?(scheme) ? url : nil
    end

    uri = UrlHelper.relaxed_parse(iframe["src"].to_s)
    return nil if uri.nil? || uri.host&.downcase != VIMEO_PLAYER_HOST

    video_id = VIMEO_PLAYER_PATH.match(uri.path.to_s)&.[](:id)
    return nil if video_id.nil?

    unlisted_hash = vimeo_unlisted_hash(uri.query)
    if unlisted_hash
      "https://vimeo.com/#{video_id}/#{unlisted_hash}"
    else
      "https://vimeo.com/#{video_id}"
    end
  end

  def self.vimeo_unlisted_hash(query)
    return nil if query.blank?

    hash = URI.decode_www_form(query).to_h["h"]
    hash if hash&.match?(VIMEO_UNLISTED_HASH)
  rescue ArgumentError
    nil
  end

  def self.strip_secure_uploads(doc)
    # images inside a lightbox or other link
    doc
      .css("a[href]")
      .each do |a|
        next if !Upload.secure_uploads_url?(a["href"])

        non_image_media = %w[video audio].include?(a&.parent&.name)
        target = non_image_media ? a.parent : a
        if target.to_s.include?("stripped-secure-view-media") ||
             target.to_s.include?("stripped-secure-view-upload")
          next
        end

        next if a.css("img[src]").empty? && !non_image_media

        if a.classes.include?("lightbox")
          img = a.css("img[src]").first
          srcset = img&.attributes&.[]("srcset")&.value
          if srcset
            # if available, use the first image from the srcset here
            # so we get the optimized image instead of the possibly huge original
            url = srcset.split(",").first
          else
            url = img["src"]
          end
          a.add_next_sibling secure_uploads_placeholder(
                               doc,
                               url,
                               width: img["width"],
                               height: img["height"],
                             )
          a.remove
        else
          width = non_image_media ? nil : a.at_css("img").attr("width")
          height = non_image_media ? nil : a.at_css("img").attr("height")
          target.add_next_sibling secure_uploads_placeholder(
                                    doc,
                                    a["href"],
                                    width: width,
                                    height: height,
                                  )
          target.remove
        end
      end

    # images by themselves or inside a onebox
    doc
      .css("img[src]")
      .each do |img|
        url =
          if img.parent.classes.include?("aspect-image") && img.attributes["srcset"].present?
            # we are using the first image from the srcset here so we get the
            # optimized image instead of the original, because an optimized
            # image may be used for the onebox thumbnail
            srcset = img.attributes["srcset"].value
            srcset.split(",").first
          else
            img["src"]
          end

        width = img["width"]
        height = img["height"]
        onebox_type = nil

        if img.ancestors.css(".onebox-body").any?
          if img.classes.include?("onebox-avatar-inline")
            onebox_type = "avatar-inline"
          else
            onebox_type = "thumbnail"
          end
        end

        # we always want this to be tiny and without any special styles
        if img.classes.include?("site-icon")
          onebox_type = nil
          width = 16
          height = 16
        end

        if Upload.secure_uploads_url?(url)
          img.add_next_sibling secure_uploads_placeholder(
                                 doc,
                                 url,
                                 onebox_type: onebox_type,
                                 width: width,
                                 height: height,
                               )
          img.remove
        end
      end
  end

  def self.secure_uploads_placeholder(doc, url, onebox_type: false, width: nil, height: nil)
    notice = doc.document.create_element("div")
    notice["class"] = "secure-upload-notice"
    notice["data-stripped-secure-upload"] = url.to_s
    notice["data-onebox-type"] = onebox_type.to_s if onebox_type

    width = numeric_dimension(width)
    height = numeric_dimension(height)
    notice["data-width"] = width if width
    notice["data-height"] = height if height

    link = doc.document.create_element("a")
    link["class"] = "stripped-secure-view-upload"
    link["href"] = url.to_s
    link.content = I18n.t("emails.view_redacted_media")

    notice.add_child(
      doc.document.create_text_node("#{I18n.t("emails.secure_uploads_placeholder")} "),
    )
    notice.add_child(link)
    notice.add_child(doc.document.create_text_node("."))
    notice
  end

  def self.numeric_dimension(value)
    value.to_s[/\A\d+\z/]
  end

  def self.format_for_email(html, post = nil)
    doc = Nokogiri::HTML5.fragment(html)
    DiscourseEvent.trigger(:reduce_cooked, doc, post)
    strip_secure_uploads(doc) if post&.should_secure_uploads?
    strip_image_wrapping(doc)
    convert_vimeo_iframes(doc)
    make_all_links_absolute(doc)
    doc.to_html
  end

  protected

  class JavaScriptError < StandardError
    attr_accessor :message, :backtrace

    def initialize(message, backtrace)
      @message = message
      @backtrace = backtrace
    end
  end

  def self.protect
    rval = nil
    @mutex.synchronize do
      rval = yield
      v8.low_memory_notification if GlobalSetting.mini_racer_single_threaded
    end
    rval
  end

  def self.cleanup(html, opts = {})
    doc = Nokogiri::HTML5.fragment(html)

    add_nofollow = !opts[:omit_nofollow] && SiteSetting.add_rel_nofollow_to_user_content
    add_rel_attributes_to_user_content(doc, add_nofollow)
    strip_hidden_unicode_bidirectional_characters(doc)
    sanitize_hotlinked_media(doc)
    add_video_placeholder_image(doc)

    add_mentions(doc, user_id: opts[:user_id]) if SiteSetting.enable_mentions

    scrubber = Loofah::Scrubber.new { |node| node.remove if node.name == "script" }
    loofah_fragment = Loofah.html5_fragment(doc.to_html)
    loofah_fragment.scrub!(scrubber).to_html
  end

  private

  USER_TYPE = "user"
  GROUP_TYPE = "group"
  GROUP_MENTIONABLE_TYPE = "group-mentionable"

  def self.add_mentions(doc, user_id: nil)
    elements = doc.css("span.mention")
    names = elements.map { |element| element.text[1..-1] }

    mentions = lookup_mentions(names, user_id: user_id)

    elements.each do |element|
      name = element.text[1..-1]
      name.downcase!

      if type = mentions[name]
        element.name = "a"

        element.children = PrettyText::Helpers.format_username(element.children.text)

        case type
        when USER_TYPE
          element["href"] = "#{Discourse.base_path}/u/#{UrlHelper.encode_component(name)}"
        when GROUP_MENTIONABLE_TYPE
          element["class"] = "mention-group notify"
          element["href"] = "#{Discourse.base_path}/groups/#{UrlHelper.encode_component(name)}"
        when GROUP_TYPE
          element["class"] = "mention-group"
          element["href"] = "#{Discourse.base_path}/groups/#{UrlHelper.encode_component(name)}"
        end
      end
    end
  end

  def self.lookup_mentions(names, user_id: nil)
    return {} if names.blank?

    sql = <<~SQL
    (
      SELECT
        :user_type AS type,
        username_lower AS name
      FROM users
      WHERE username_lower IN (:names) AND staged = false
    )
    UNION
    (
      SELECT
        :group_type AS type,
        lower(name) AS name
      FROM groups
    )
    UNION
    (
      SELECT
        :group_mentionable_type AS type,
        lower(name) AS name
      FROM groups
      WHERE lower(name) IN (:names) AND (#{Group.mentionable_sql_clause(include_public: false)})
    )
    ORDER BY type
    SQL

    user = User.find_by(id: user_id)
    names.each(&:downcase!)

    results =
      DB.query(
        sql,
        names: names,
        user_type: USER_TYPE,
        group_type: GROUP_TYPE,
        group_mentionable_type: GROUP_MENTIONABLE_TYPE,
        levels: Group.alias_levels(user),
        user_id: user_id,
      )

    mentions = {}
    results.each { |result| mentions[result.name] = result.type }
    mentions
  end

  def self.allowed_src_pattern
    allowed_src_prefixes = [
      Discourse.base_path,
      Discourse.base_url,
      GlobalSetting.s3_cdn_url,
      GlobalSetting.cdn_url,
      SiteSetting.external_emoji_url.presence,
      *SiteSetting.block_hotlinked_media_exceptions.split("|"),
    ]

    patterns =
      allowed_src_prefixes.compact.map do |url|
        pattern = Regexp.escape(url)

        # If 'https://example.com' is allowed, ensure 'https://example.com.blah.com' is not
        pattern += '(?:/|\z)' if !pattern.ends_with?("\/")

        pattern
      end

    /\A(data:|#{patterns.join("|")})/
  end
end
