#!/usr/bin/ruby

require 'logger'
require 'yaml'
require 'optparse'
require 'optparse/time'

class UpdateAllTheThings
  def initialize
    @version = "0.0.27"

    @loglog = Logger.new(STDERR)
    @loglog.level = Logger::INFO
    @loglog.info("Welcome to #{$0} v#{@version}")

    @cmdline_mode = :unset
    @has_orphaned_flag = false
    @zypper_download_only = false

    parse_options
    parse_config
    check_for_orphaned_flag
    ensure_root

    zypper_update

    ['podman', 'docker'].each do |container_engine|
      skip_container_engines_key = "skip_container_engines"
      if @config[skip_container_engines_key]&.include?(container_engine)
        @loglog.info "Skipping container engine #{container_engine}"
        next
      end
      if which(container_engine)

        if system("#{container_engine} info", :out => File::NULL, :err => File::NULL)
          container_update(container_engine)
        else
          @loglog.warn("Container engine #{container_engine} seems inactive, not updating")
        end
      else
        @loglog.warn("Container engine #{container_engine} not found")
      end
    end
    flatpak_it

    zypper_ps_reminder
  end

  private

  def flatpak_it
    unless @config.fetch("skip_flatpak", false)
      @loglog.info("Checking for flatpak updates")
      if which("flatpak")
        if system("flatpak", "update")
          @loglog.info("flatpak update successful")
        else
          @loglog.warn("flatpak update failed")
        end
        if system("flatpak", "uninstall", "--unused")
          @loglog.info("flatpak uninstall of unused packages successful")
        else
          @loglog.warn("flatpak uninstall of unused packages failed")
        end
      end
    end
  end

  def exit_with_error(error_code, error_message)
    @loglog.error(error_message)
    exit(error_code)
  end

  def ensure_root
    if Process.euid != 0
      exit_with_error(2, "This script requires root privileges. Exiting.")
    end
  end

  def parse_options

    opt_parser = OptionParser.new do |opts|
      opts.banner = "Welcome to #{$0} v#{@version}\n\nUsage: #{$0} [options]"

      opts.separator ""
      opts.separator "Specific options:"

      opts.on('--mode [mode]',  '-m [mode]', "In which mode should we run. This overrides the config setting. possible values: desktop|full default value: full") do |run_mode|
        case run_mode
        when "desktop"
          @cmdline_mode = :desktop
        when "full"
          @cmdline_mode = :full
        else
          exit_with_error(1, "Unknown run mode: #{run_mode}. Exiting.")
        end
      end
      opts.on('ignore-updated-kernel', '-k', 'Ignore that the kernel was updated and restart services anyway.') do
        @ignore_kernel_is_updated = true
      end
      opts.on('download-only', '-d', 'Only download the updates with zypper') do
        @zypper_download_only = true
      end
    end
    rest = opt_parser.parse!(ARGV)
  end

  def parse_config
    config_path = "/etc/update-all-the-things.yml"

    @config = {}
    @restart_blocked_services = []

    if File.exist?(config_path)
      @loglog.info("Found #{config_path}")
      @config = YAML.load(File.read(config_path))
    end

    desktop_mode_key = "desktop_mode"
    if (@config.include? desktop_mode_key and @config[desktop_mode_key]) and (@cmdline_mode != :full)
       desktop_services_ignored = %w{
        accounts-daemon
        dbus-broker
        dbus
        display-manager
        gdm
        kdm
        sddm
        polkit
        systemd-logind
      }
      @loglog.info("Running in desktop mode. The following services will not be restarted #{desktop_services_ignored}")
      @restart_blocked_services += desktop_services_ignored
    else
      @loglog.info("All services except excluded ones will be restarted")
    end

    service_restart_exclude_key = "service_restart_excludes"
    unless @config.fetch(service_restart_exclude_key, []).empty?
      @loglog.info("The following services are excluded from the restart #{@config[service_restart_exclude_key]}")
      @restart_blocked_services += @config[service_restart_exclude_key]
    end
  end

  def check_for_orphaned_flag
    IO.popen(["zypper", "--version"]) do |zypper_version_fd|
      zypper_version = zypper_version_fd.read
      prgname, prgversion = zypper_version.split(/\s+/, 2)
      @has_orphaned_flag = Gem::Version.new(prgversion) >= Gem::Version.new("1.14.70")
      @loglog.debug("We can use the --remove-orphaned") if @has_orphaned_flag
    end
  end

  #
  # from https://stackoverflow.com/questions/2108727/which-in-ruby-checking-if-program-exists-in-path-from-ruby
  # Cross-platform way of finding an executable in the $PATH.
  #
  #   which('ruby') #=> /usr/bin/ruby
  def which(cmd)
    exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
    ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
      exts.each do |ext|
        exe = File.join(path, "#{cmd}#{ext}")
        return exe if File.executable?(exe) && !File.directory?(exe)
      end
    end
    nil
  end

  def zypper_services_need_restart
      services_need_restart = []

      IO.popen(["zypper", "ps", "--print", '%s']) do |zypper_ps_fd|
        services_need_restart = zypper_ps_fd.readlines.map { |service| service.chomp }
      end

      matched_services = services_need_restart & @restart_blocked_services
      unless matched_services.empty?
        @loglog.info("Filter out services that should be excluded from restarting: #{@restart_blocked_services} ")
        services_need_restart -= @restart_blocked_services
      end

      if (system("/usr/bin/systemctl is-active systemd-logind > /dev/null 2>&1") and services_need_restart.include?("dbus-broker") and not(services_need_restart.include?("systemd-logind")))
        @loglog.info("dbus-broker is restarted and systemd-logind is not. Inject service as well.")
        services_need_restart << "systemd-logind"
      end

      services_need_restart
  end

  def zypper_update
    @loglog.info("Updating repositories and running dup")

    zypper_dup_cmdline = [ "zypper", "dup" ]
    if @has_orphaned_flag
      zypper_dup_cmdline << "--remove-orphaned"
    end
    if @zypper_download_only
      zypper_dup_cmdline << "--download-only"
    end
    if system("zypper", "ref") and system(*zypper_dup_cmdline)
      @loglog.info("Dup successful")

      install_missing_x86_64_v3_packages

      unless @config.fetch('skip_purge_kernel', false)
        @loglog.info("Purging kernels")
        system("zypper", "purge-kernels")
      else
        @loglog.info("Not purging kernels")
      end

      reboot_hint = system("/usr/bin/needs-restarting --reboothint")
      complex_expression = not(reboot_hint) or not(@ignore_kernel_is_updated)
      if complex_expression then
        @loglog.info("Not restarting services as reboot is needed")
      elsif @zypper_download_only
        @loglog.info("Download only mode. No restarts needed.")
      else
        @loglog.info("Getting Services that need restart")
        services_need_restart = zypper_services_need_restart

        unless services_need_restart.empty?
          @loglog.info("Trying to reload or restart all services: #{services_need_restart}")
          system("systemctl", "reload", *services_need_restart)
        end

        services_need_restart = zypper_services_need_restart

        unless services_need_restart.empty?
          @loglog.info("Restarting all services: #{services_need_restart}")
          system("systemctl", "restart", *services_need_restart)
        end
      end

      @loglog.info("Restarting pid 1")
      system("systemctl", "daemon-reexec")
    else
      @loglog.warn("refresh or dup failed")
    end
  end

  def zypper_ps_reminder
    @loglog.info("Check if you want to restart/kill any of the following processes:")
    system("zypper", "ps")
  end

  def skip_pull_container(container_engine, container_url)
    (@config["skip_pull_containers"]&.fetch(container_engine)&.include?(container_url))
  end

  def container_update(container_engine="podman")
    @loglog.info("[#{container_engine}] Updating images")
    install_image_list = []

    @loglog.info("[#{container_engine}] Getting images list for pull")
    IO.popen([container_engine, "image", "ls", "--all", "--format", "{{.Repository}}:{{.Tag}}"]) do |image_list_fd|
      install_image_list = image_list_fd.readlines.reject { |url| url =~ /<none>/ }
    end


    install_image_list.each do |image_url|
      image_url.chomp!
      if skip_pull_container(container_engine, image_url)
        @loglog.info("[#{container_engine}] Skipping pull for container #{image_url}")
        next
      end
      @loglog.info("[#{container_engine}] Pulling #{image_url}")
      system(container_engine, "pull", image_url)
    end

    image_cleanup_list = []

    @loglog.info("[#{container_engine}] Getting images list for prune")
    IO.popen([container_engine, "image", "ls", "--all", "--format", '{{.Repository}}:{{.Tag}} {{.ID}}']) do |image_list_fd|
      image_list_fd.each do |image_url|
        if image_url =~ /:<none>/
          image_url, image_id = image_url.split(/\s+/)
          image_cleanup_list << image_id
        end
      end
    end

    unless image_cleanup_list.empty?
      @loglog.info("[#{container_engine}] Cleaning up the following image ids: #{container_engine} #{image_cleanup_list.join(", ")}")
      system(container_engine, "rmi", *image_cleanup_list)
    end

    @loglog.info("[#{container_engine}] Current images list")
    system(container_engine, "image", "ls", "--all")
  end

  def install_missing_x86_64_v3_packages
    return if "x86_64" != RbConfig::CONFIG["host_cpu"]
    @loglog.info("Checking for and installing missing x86-64-v3 packages")
    @loglog.debug("Find all x86-64-v3 packages")

    missing_x86_64_v3_packages_per_repo={}
    missing_x86_64_v3_packages = {}

    pkg_missing_line_re   = /\A(?<install_state>\s+)\|\s+(?<pkgname>\S+)\s+\|\s+(?<type>\S+)\s+\|\s+(?<version>\S+)\s+\|\s+(?<arch>\S+)\s+\|\s+(?<repo>\S+)\s*\z/
    pkg_installed_line_re = /\A(?<install_state>i\S*)\s+\|\s+(?<pkgname>\S+)\s+\|\s+(?<type>\S+)\s+\|\s+(?<version>\S+)\s+\|\s+(?<arch>\S+)\s+\|\s+(?<repo>\S+)\s*\z/


    IO.popen("zypper --no-refresh se -t package -s x86-64-v3") do |zypper_io|
      zypper_io.read.each_line do |line|
        line.chomp!
        if mo=pkg_missing_line_re.match(line)
          pkgname    = mo[:pkgname]
          pkgversion = mo[:version]
          pkgarch    = mo[:arch]

          next if pkgname =~ /-debuginfo/

          base_package = pkgname.gsub('-x86-64-v3', '')
          version_arch_suffix = "-#{pkgversion}.#{pkgarch}"
          missing_x86_64_v3_packages["#{base_package}#{version_arch_suffix}"] = "#{pkgname}#{version_arch_suffix}"
        end
      end
    end


    @loglog.debug("Checking all the base packages")
    base_packages = missing_x86_64_v3_packages.keys
    return if base_packages.empty?

    IO.popen(['zypper', '--no-refresh', 'se', '-t', 'package', '-s', *base_packages, :err=>[:child, :out]]) do |zypper_io|
      zypper_io.read.each_line do |line|
        line.chomp!
        if mo=pkg_installed_line_re.match(line)
          repo = mo[:repo]
          base_package = "#{mo[:pkgname]}-#{mo[:version]}.#{mo[:arch]}"
          missing_x86_64_v3_packages_per_repo[repo] ||= []
          missing_x86_64_v3_packages_per_repo[repo] << missing_x86_64_v3_packages[base_package]
        end
      end
    end

    missing_x86_64_v3_packages_per_repo.each do |repo, pkgs|
      cmdline = ["zypper", "install", "--from", repo]
      if @zypper_download_only
        cmdline << "--download-only"
      end
      cmdline.concat(pkgs)
      @loglog.debug("Installing x86-64-v3 packages from #{repo}")
      system(*cmdline)
    end
  end
end

UpdateAllTheThings.new
