#!/usr/bin/env ruby
# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  gem "webdrivers"
  gem "colored2"
end

require "fileutils"
require "optparse"
require "yaml"

DEFAULT_OUTPUT_PATH = "/shared/import/data"
DEFAULT_COOKIES_TXT = "/shared/import/cookies.txt"
ABORT_AFTER_SKIPPED_TOPIC_COUNT = 10

def driver
  @driver ||=
    begin
      chrome_args = ["disable-gpu"]
      chrome_args << "headless" unless ENV["NOT_HEADLESS"] == "1"
      chrome_args << "no-sandbox" if inside_container?
      options = Selenium::WebDriver::Chrome::Options.new(args: chrome_args)
      Selenium::WebDriver.for(:chrome, options: options)
    end
end

def inside_container?
  File.foreach("/proc/1/cgroup") { |line| return true if line.include?("docker") }

  false
end

MAX_GET_RETRIES = 5
MAX_FIND_RETRIES = 3

def get(url)
  begin
    retries ||= 0
    driver.get(url)
  rescue Net::ReadTimeout
    sleep retries
    retry if (retries += 1) < MAX_GET_RETRIES
  end
end

def extract(css, parent_element = driver)
  begin
    retries ||= 0
    parent_element.find_elements(css: css).map { |element| yield(element) }
  rescue Net::ReadTimeout, Selenium::WebDriver::Error::StaleElementReferenceError
    sleep retries
    retry if (retries += 1) < MAX_FIND_RETRIES
  end
end

def find(css, parent_element = driver)
  begin
    retries ||= 0
    parent_element.find_element(css: css)
  rescue Net::ReadTimeout, Selenium::WebDriver::Error::ElementNotInteractableError
    sleep retries
    retry if (retries += 1) < MAX_FIND_RETRIES
  end
end

def base_url
  if @domain.nil?
    "https://groups.google.com/forum/?_escaped_fragment_=categories"
  else
    "https://groups.google.com/a/#{@domain}/forum/?_escaped_fragment_=categories"
  end
end

def crawl_topics
  1
    .step(nil, 100)
    .each do |start|
      url = "#{base_url}/#{@groupname}[#{start}-#{start + 99}]"
      get(url)

      begin
        exit_with_error(<<~TEXT.red.bold) if start == 1 && find("h2").text == "Error 403"
          Unable to find topics. Try running the script with the "--domain example.com"
          option if you are a G Suite user and your group's URL contains a path with
          your domain that looks like "/a/example.com".
        TEXT
      rescue Selenium::WebDriver::Error::NoSuchElementError
        # Ignore this error. It simply means there wasn't an error.
      end

      topic_urls =
        extract(".subject a[href*='#{@groupname}']") do |a|
          a["href"].sub("/d/topic/", "/forum/?_escaped_fragment_=topic/")
        end
      break if topic_urls.size == 0

      topic_urls.each do |topic_url|
        crawl_topic(topic_url)

        # abort if this in an incremental crawl and there were too many consecutive, skipped topics
        if @finished && @skipped_topic_count > ABORT_AFTER_SKIPPED_TOPIC_COUNT
          puts "Skipping all other topics, because this is an incremental crawl.".green
          return # rubocop:disable Lint/NonLocalExitFromIterator
        end
      end
    end
end

def crawl_topic(url)
  skippable = @scraped_topic_urls.include?(url)

  # Skip this topic if there were already too many consecutive, skipped topics.
  # Otherwise we have to look if there are new messages in the topic.
  if skippable && @skipped_topic_count > ABORT_AFTER_SKIPPED_TOPIC_COUNT
    puts "Skipping".green << " #{url}"
    return
  end

  puts "Scraping #{url}"
  get(url)

  messages_crawled = false

  extract(".subject a[href*='#{@groupname}']") do |a|
    [a["href"].sub("/d/msg/", "/forum/message/raw?msg="), a["title"].empty?]
  end.each do |msg_url, might_be_deleted|
    messages_crawled |= crawl_message(msg_url, might_be_deleted)
  end

  @skipped_topic_count = skippable && messages_crawled ? 0 : @skipped_topic_count + 1
  @scraped_topic_urls << url
rescue StandardError
  puts "Failed to scrape topic at #{url}".red
  raise if @abort_on_error
end

def crawl_message(url, might_be_deleted)
  get(url)

  filename = File.join(@path, "#{url[%r{#{@groupname}/(.+)}, 1].sub("/", "-")}.eml")
  content = find("pre")["innerText"]

  if !@first_message_checked
    @first_message_checked = true

    exit_with_error(<<~TEXT.red.bold) if content.match?(/From:.*\.\.\.@.*/i) && !@force_import
        It looks like you do not have permissions to see email addresses. Aborting.
        Use the --force option to import anyway.
      TEXT
  end

  old_md5 = Digest::MD5.file(filename) if File.exist?(filename)
  File.write(filename, content)

  old_md5 ? old_md5 != Digest::MD5.file(filename) : true
rescue Selenium::WebDriver::Error::NoSuchElementError
  if might_be_deleted
    puts "Message might be deleted. Skipping #{url}"
  else
    puts "Failed to scrape message at #{url}".red
    raise if @abort_on_error
  end
rescue StandardError
  puts "Failed to scrape message at #{url}".red
  raise if @abort_on_error
end

def login
  puts "Logging in..."
  get("https://google.com/404")

  add_cookies("myaccount.google.com", "google.com")

  get("https://myaccount.google.com/?utm_source=sign_in_no_continue")

  begin
    wait_for_url { |url| url.start_with?("https://accounts.google.com") }
  rescue Selenium::WebDriver::Error::TimeoutError
    exit_with_error("Failed to login. Please check the content of your cookies.txt".red.bold)
  end
end

def add_cookies(*domains)
  File
    .readlines(@cookies)
    .each do |line|
      parts = line.chomp.split("\t")
      if parts.size != 7 || !domains.any? { |domain| parts[0] =~ /^\.?#{Regexp.escape(domain)}$/ }
        next
      end

      driver.manage.add_cookie(
        domain: parts[0],
        httpOnly: "true".casecmp?(parts[1]),
        path: parts[2],
        secure: "true".casecmp?(parts[3]),
        expires: parts[4] == "0" ? nil : DateTime.strptime(parts[4], "%s"),
        name: parts[5],
        value: parts[6],
      )
    end
end

def wait_for_url
  wait = Selenium::WebDriver::Wait.new(timeout: 5)
  wait.until { yield(driver.current_url) }
end

def exit_with_error(*messages)
  STDERR.puts messages
  exit 1
end

def crawl
  start_time = Time.now
  status_filename = File.join(@path, "status.yml")

  if File.exist?(status_filename)
    yaml = YAML.load_file(status_filename)
    @finished = yaml[:finished]
    @scraped_topic_urls = yaml[:urls]
  else
    @finished = false
    @scraped_topic_urls = Set.new
  end

  @skipped_topic_count = 0

  login

  begin
    crawl_topics
    @finished = true
  ensure
    File.write(status_filename, { finished: @finished, urls: @scraped_topic_urls }.to_yaml)
  end

  elapsed = Time.now - start_time
  puts "", "", "Done (%02dh %02dmin %02dsec)" % [elapsed / 3600, elapsed / 60 % 60, elapsed % 60]
end

def parse_arguments
  puts ""

  # default values
  @force_import = false
  @abort_on_error = false
  @cookies = DEFAULT_COOKIES_TXT if File.exist?(DEFAULT_COOKIES_TXT)

  parser =
    OptionParser.new do |opts|
      opts.banner = "Usage: google_groups.rb [options]"

      opts.on("-g", "--groupname GROUPNAME") { |v| @groupname = v }
      opts.on("-d", "--domain DOMAIN") { |v| @domain = v }
      opts.on("-c", "--cookies PATH", "path to cookies.txt") { |v| @cookies = v }
      opts.on("--path PATH", "output path for emails") { |v| @path = v }
      opts.on("-f", "--force", "force import when user isn't allowed to see email addresses") do
        @force_import = true
      end
      opts.on("-a", "--abort-on-error", "abort crawl on error instead of skipping message") do
        @abort_on_error = true
      end
      opts.on("-h", "--help") do
        puts opts
        exit
      end
    end

  begin
    parser.parse!
  rescue OptionParser::ParseError => e
    exit_with_error(e.message, "", parser)
  end

  mandatory = %i[groupname cookies]
  missing = mandatory.select { |name| instance_variable_get("@#{name}").nil? }

  if missing.any?
    exit_with_error("Missing arguments: #{missing.join(", ")}".red.bold, "", parser, "")
  end
  exit_with_error("cookies.txt not found at #{@cookies}".red.bold, "") if !File.exist?(@cookies)

  @path = File.join(DEFAULT_OUTPUT_PATH, @groupname) if @path.nil?
  FileUtils.mkpath(@path)
end

parse_arguments
crawl