#!/usr/bin/ruby

require 'rexml/document'
require 'fileutils'
require 'open3'
require 'csv'
require 'optparse'
require 'optparse/time'
require 'date'

module SUSE
  module Zypper
    class << self

      def print_messages(xml_doc)
        xml_doc.root.elements.each("message") do |e|
          print e[0].value, "\n"
        end
      end

      def call(args, quiet = true)
        cmd = "zypper #{args}"
        output, error, status = Open3.capture3({ 'LC_ALL' => 'C' }, cmd) {|_stdin, stdout, _stderr, _wait_thr| stdout.read }

        # Don't fail when zypper exits with 104 (no product found) or 6 (no repositories)
        valid_exit_codes = [0, 104, 6]
        # Catching interactive failures of zypper. --non-interactive always returns with exit code 0 here
        if !valid_exit_codes.include?(status.exitstatus) || error.include?('ABORT request')
          error = error.empty? ? output.strip : error.strip
          begin
            if error.start_with?("<?xml")
              xml_doc = REXML::Document.new(error)
              print_messages(xml_doc)
              error = nil
            end
          rescue
            # ignore
          end

          print "#{error}\n" if !error.nil?
          exit(status.exitstatus)
        end
        output.strip unless quiet
      end

      def xml_call(args, root, subpath = [])
        zypper_out = call(args, false)
        xml_doc = REXML::Document.new(zypper_out, compress_whitespace: [])
        ary_of_products_hashes = xml_doc.root.elements[root].elements.map do |e|
          h = {}
          e.attributes.each { |name, value| h[name.to_sym] = value } # add attributes
          subpath.each do |sp|
            next if e.elements[sp].nil?
            fsp = sp.gsub(/\//, '_')
            e.elements[sp].attributes.each { |name, value| h["#{fsp}_#{name}".to_sym] = value } # add attributes of requested sub-elements
            if e.elements[sp].has_text?()
              h["#{fsp}".to_sym] = e.elements[sp].get_text().to_s
            end
          end
          h
        end
      end

    end
  end

  class Lifecycle

    # allow sorting by numeric value
    NowTS   = 0
    NaTS    = 1000000000000000
    NeverTS = 1000000000000001
    ProductFormatStr = "%-55s %s\n"

    def initialize(verbose)
        @verbose = verbose
        @retval = 0
    end

    def load_zypper_data
      repo_list = Zypper::xml_call("--no-refresh -x lr", 'repo-list')
      repo_alias = {}
      repo_list.each do |r|
        repo_alias[r[:alias]] = r[:name]
        repo_alias[r[:name]] = r[:name]
      end

      product_list = Zypper::xml_call("--no-refresh -x pd --xmlfwd codestream", 'product-list',
          ['endoflife', 'registerflavor', 'xmlfwd/codestream/name', 'xmlfwd/codestream/endoflife'])

      product_by_repo = {}
      @installed_products = {}
      product_list.each do |p|
        if !p.key?(:endoflife_time_t)
           p[:endoflife_time_t] = NeverTS
        else
          p[:endoflife_time_t] = p[:endoflife_time_t].to_i
          p[:endoflife_time_t] = NaTS if p[:endoflife_time_t] == 0
        end
        if !p.key?(:xmlfwd_codestream_endoflife)
           p[:codestream_endoflife_time_t] = NeverTS
        else
          begin
            p[:codestream_endoflife_time_t] = Date::parse(p[:xmlfwd_codestream_endoflife]).strftime("%s").to_i
          rescue
            p[:codestream_endoflife_time_t] = NaTS
          end
        end
        if p.key?(:xmlfwd_codestream_name)
          p[:codestream_name] = p[:xmlfwd_codestream_name]
        else
          p[:codestream_name] = ''
        end
        repo = repo_alias[p[:repo]]
        product_by_repo[repo] = p
        @installed_products[p[:name]] =  p if p[:installed] == "true"
      end

      @all_packages = Zypper::xml_call("--no-refresh -x se -s -t package", 'search-result/solvable-list')
      @installed_package = {}
      @all_packages.each do |p|
        if p[:kind] == 'package' && p[:status] == 'installed'
          @installed_package[p[:name]] ||= []
          @installed_package[p[:name]] << p
        end
      end

      #hack - guess that repos <name>-Pool and <name>-Updates are the same product
      repo_alias.values.each do |name|
        updates = name.gsub(/-Pool/, '-Updates')
        if updates != name && product_by_repo[name] && !product_by_repo[updates]
          product_by_repo[updates] = product_by_repo[name]
          print "#{updates} is an update repo for #{product_by_repo[name][:name]}\n" if @verbose
        end
      end

      update_list = Zypper::xml_call("--no-refresh -x lu", 'update-status/update-list', ['source'])
      update_list.each do |u|
        (@installed_package[u[:name]] || []).each do |p|
          if p[:arch] == u[:arch]
            p[:update_edition] = u[:edition]
            p[:repository] = repo_alias[u[:source_alias]] if p[:repository] == "(System Packages)"
          end
        end
      end

      @all_packages.each do |p|
        p[:product] = product_by_repo[p[:repository]]
      end

      # if there are the same versions of a package in multiple products, keep the one with longest product life
      @installed_package.values.each do |p_list|
        p_list.sort_by!{ |p| [p[:edition], p[:product] ? p[:product][:endoflife_time_t] : 0] }.reverse!.uniq!{ |p| p[:edition] }
      end

    end

    def load_lifecycle_data()
      @lifecycle_data = []
      @successors = {}
      @installed_products.values.each do |product|
        [
          "/var/lib/lifecycle/data/#{product[:name]}.lifecycle",
          "/usr/lib/lifecycle/data/#{product[:name]}.lifecycle",
          "/usr/share/lifecycle/data/#{product[:name]}.lifecycle"
        ].each do |file|
          print "trying to load #{file}... " if @verbose
          begin
            CSV.foreach(file, :skip_blanks => true, :skip_lines => /^\s*#/ ) do |line|
              line.each{ |f| f = "" unless f; f.strip! }
              @lifecycle_data.append(line)
            end
            print "ok\n" if @verbose
          rescue Errno::ENOENT => e
            print "#{e.message}\n" if @verbose
          rescue Exception => e
            print "\nError parsing #{file}: #{e.message}\n"
            @retval = 2
          end
        end
      end
      parse_lifecycle_data(1)
      # currenly we do not print successor EOL, so second pass is not needed
      # parse_lifecycle_data(2)
    end


    def parse_lifecycle_data(pass)
      if pass==1
        target_hash = @installed_package
      else
        target_hash = @successors
      end
      @lifecycle_data.each do |line|
        begin
          name, version, date, successor = line

          if !date || date.empty?
            date = nil
          else
            date = Time.parse(date).strftime("%s")
          end
          version_re = Regexp.new( '^' + Regexp.quote(version).gsub(/\\\*/, '.*') + '$')
          #print "match #{name} #{version_re}\n"
          name_re = nil
          if name.start_with?("/") && name.end_with?("/")
            name_re = Regexp.new(name[1..-2])
            matching_names = target_hash.keys.select { |p| name_re.match(p) }
            matching_installed = matching_names.map { |p| target_hash[p] }.flatten(1)
          elsif name.include? "*"
            name_re = Regexp.new( '^' + Regexp.quote(name).gsub(/\\\*/, '(.*)') + '$')
            matching_names = target_hash.keys.select { |p| name_re.match(p) }
            matching_installed = matching_names.map { |p| target_hash[p] }.flatten(1)
          else
            matching_installed = target_hash[name]
          end
          (matching_installed || []).each do |p|
            if version_re.match(p[:edition])
              p[:package_eol] = date
            end
            if version_re.match(p[:update_edition])
              p[:update_package_eol] = date
            end
            p[:version_specified] = version

            if pass == 1 && successor
              handle_successor(p, successor, name_re)
            end
          end
        rescue Exception => e
          print "\nError parsing #{line}: #{e.message}\n"
            @retval = 2
        end
      end
    end

    def handle_successor(package, successor, name_re)
      matches = [package[:name]]
      if name_re
          m = name_re.match(package[:name])
          matches += m.captures if m
      end
      if successor.start_with?("/") && successor.end_with?("/")
          successor_re = successor[1..-2]
      else
          successor_re = '^' + Regexp.quote(successor).gsub(/\\\*/, '.*') + '$'
      end
      for i in 0..9
          successor_re.gsub!(/<#{i}>/, Regexp.quote(matches[i] || ''))
      end
      successor_re = Regexp.new(successor_re)
      matching_successors = @all_packages.select { |s| successor_re.match(s[:name]) && package[:name] != s[:name] && package[:arch] == s[:arch] }

      # select the successor with highest version
      successor_version = package[:edition]
      successor_pkg = nil
      matching_successors.each do |s|
        if Gem::Version.new(s[:edition]) > Gem::Version.new(successor_version)
          successor_pkg = s
          successor_version = s[:edition]
        end
      end

      if successor_pkg
        package[:successor] = successor_pkg
        @successors[successor_pkg[:name]] ||= []
        @successors[successor_pkg[:name]] << successor_pkg
      end
    end

    def solve_package_eol()
      now = Time.now.strftime("%s").to_i

      @installed_package.values.flatten.each do |p|
        eol = nil
        if p[:package_eol] # eol specified in lifecycle file
          eol = p[:package_eol].to_i
          eol = NowTS if eol <= now
        end
        eol = NowTS if !eol && p[:update_edition] # update exists -> this package is expired
        eol = p[:product][:endoflife_time_t].to_i if !eol && p[:product] && p[:product][:endoflife_time_t] # default to product eol
        eol = NeverTS if !eol
        p[:eol] = eol

        if p[:update_edition]
          up_eol = nil
          up_eol = p[:update_package_eol] if p[:update_package_eol] # eol specified in lifecycle file
          up_eol = p[:product][:endoflife_time_t] if !up_eol && p[:product] && p[:product][:endoflife_time_t] # default to product eol
          p[:update_eol] = up_eol.to_i if up_eol
        end

        if p[:successor]
          p[:eol] = NowTS
        end
      end
    end

    def eol_string(eol_ts, vendor)
      eol = ''
      eol = '-' if eol_ts == NeverTS
      if eol_ts == NaTS
        eol = 'n/a'
        if Regexp.new("^SUSE").match(vendor)
          eol += '*'
          @vendor_suse = true
        elsif Regexp.new("^openSUSE").match(vendor)
          eol += '*'
          @vendor_opensuse = true
        end
      end
      eol = 'Now' if eol_ts == NowTS
      eol = Time.at(eol_ts).strftime('%F') if eol_ts && eol == ''
      eol
    end

    def load_already_reported(fn)
      begin
        File.open(fn, 'r') do |f|
          doc = REXML::Document.new f

          @report_since = Time.parse(doc.root.attributes['date'])

          doc.root.elements.map do |e|
            p = {}
            e.attributes.each { |name, value| p[name.to_sym] = value }
            if e.name == 'product'
              if @installed_products[p[:name]][:endoflife_time_t] == p[:eol].to_i
                @installed_products[p[:name]][:already_reported] = true
              end
            elsif e.name == 'package'
              @installed_package[p[:name]].each do |ip|
                if ip[:edition] == p[:edition] && ip[:eol] == p[:eol].to_i
                  ip[:already_reported] = true
                end
              end
            end
          end
        end
      rescue Errno::ENOENT => e
        print "#{e.message}\n" if @verbose
      rescue Exception => e
        print "\nError parsing #{fn}: #{e.message}\n"
        @retval = 2
      end
    end

    def save_report(fn, products, packages)
      begin
        doc = REXML::Document.new
        report= doc.add_element 'report', {"date" => Time.now }
        products.each do |p|
          report.add_element "product", {"name" => p[:name], "eol" => p[:endoflife_time_t]}
        end
        packages.each do |p|
          report.add_element "package", {"name" => p[:name], "edition" => p[:edition], "eol" => p[:eol]}
        end
        File.open(fn, 'w') do |f|
          doc.write(f, 4)
        end
      rescue Exception => e
        print "\nError saving #{fn}: #{e.message}\n"
        @retval = 2
      end
    end

    def print_product(p)
      vendor = p[:vendor]
      if p.key?(:xmlfwd_codestream_name)
        if p[:xmlfwd_codestream_name] != @printed_codestream
          printf(ProductFormatStr, "Codestream: " + p[:xmlfwd_codestream_name], eol_string(p[:codestream_endoflife_time_t], vendor))
          @printed_codestream = p[:xmlfwd_codestream_name]
        end
        printf(ProductFormatStr, "    Product: " + p[:summary], eol_string(p[:endoflife_time_t], vendor))
      else
        printf(ProductFormatStr, p[:summary], eol_string(p[:endoflife_time_t], vendor))
        @printed_codestream = nil
      end
    end

    def print_package(p)
      vendor = ''
      vendor = p[:product][:vendor] if p[:product]
      eol = eol_string(p[:eol], vendor)
      up = ''
      if p[:successor]
        up = ", installed #{p[:edition]}, successor available #{p[:successor][:name]}-#{p[:successor][:edition]}"
        up_eol = ''
      elsif p[:update_edition]
        up = ", installed #{p[:edition]}, update available #{p[:update_edition]}"
        up_eol = ''
        up_eol = eol_string(p[:update_eol], vendor) if p[:update_eol] && p[:update_eol] < NaTS
      end
      name = p[:name]
      if p[:version_specified] && p[:version_specified] != '*'
        name += "-#{p[:version_specified]}"
      end
      printf("%-40s %-40s %s\n", name, eol + up, up_eol)
    end

    def report_products(products, msg)
      base_products = products.select{ |p| p[:isbase] == 'true'}.sort_by.with_index { |p, idx| [p[:codestream_name], p[:endoflife_time_t].to_i, idx] }
      modules = products.select{ |p| p[:registerflavor] == 'module'}.sort_by.with_index { |p, idx| [p[:codestream_name], p[:endoflife_time_t].to_i, idx] }
      extensions = products.select{ |p| p[:registerflavor] == 'extension'}.sort_by.with_index { |p, idx| [p[:codestream_name], p[:endoflife_time_t].to_i, idx] }

      if base_products.length > 0
        printf("\n" + ProductFormatStr, "Product #{msg}", "")
        @printed_codestream = nil
        base_products.each do |product|
          print_product(product)
        end
      end

      if modules.length > 0
        printf("\n" + ProductFormatStr, "Module #{msg}", "")
        @printed_codestream = nil
        modules.each do |product|
          print_product(product)
        end
      end

      if extensions.length > 0
        printf("\n" + ProductFormatStr, "Extension #{msg}", "")
        @printed_codestream = nil
        extensions.each do |product|
          print_product(product)
        end
      end

    end

    def print_lifecycle_url()
      print "\n*) See https://www.suse.com/lifecycle for latest information" if @vendor_suse
      print "\n*) See https://en.opensuse.org/Lifetime for latest information" if @vendor_opensuse
      print "\n"
    end

    def report(save_fn)
      return if @retval > 0
      if @report_since
          print "Reporting changes since #{@report_since}\n"
      end

      products = @installed_products.values
      print_products = products.select {|p| !p[:already_reported]}
      if print_products.length > 0
        report_products(print_products, "end of support")
      else
        print "\nNo products"
        print " changed since #{@report_since}" if @report_since
        print ".\n"
      end

      packages = @installed_package.values.flatten.select {|p| (p[:package_eol] || p[:update_edition] || p[:successor]) }
      print_packages = packages.select {|p| !p[:already_reported] }
      if print_packages.length > 0
        print "\nPackage end of support if different from product:\n"
        print_packages.sort_by.with_index { |p, idx| [p[:eol], idx] }.each do |p|
          print_package(p)
        end
      else
        print "\nNo packages with end of support different from product"
        print " changed since #{@report_since}" if @report_since
        print ".\n"
      end

      print_lifecycle_url()

      save_report(save_fn, @installed_products.values, packages) if save_fn

      @retval = 1 if print_products.length == 0 && print_packages.length == 0
    end

    def report_packages(list)
      print "\nPackage end of support:\n"
      list.each do |name|
        p_list = @installed_package[name]
        if !p_list
          print "#{name} is not installed\n"
          @retval = 1
        else
          p_list.each { |p| print_package(p) }
        end
      end
      print_lifecycle_url()
    end

    def report_deadline(date, save_fn)
      if @report_since
          print "Reporting changes since #{@report_since}\n"
      end
      date_ts = date.strftime("%s").to_i
      date_str = Time.at(date_ts).strftime('%F')

      products = @installed_products.values.select {|p| (p[:endoflife_time_t] && p[:endoflife_time_t].to_i <= date_ts or p[:codestream_endoflife_time_t] && p[:codestream_endoflife_time_t] <= date_ts)}
      print_products = products.select {|p| !p[:already_reported]}
      if print_products.length > 0
        report_products(print_products, "end of support before #{date_str}")
      else
        print "\nNo products whose support ends before #{date_str}.\n"
      end
      packages = @installed_package.values.flatten.select {|p| p[:eol] && p[:eol] <= date_ts }
      dif_packages = packages.select {|p| (p[:package_eol] || p[:update_edition] || p[:successor]) }
      print_packages = dif_packages.select {|p| !p[:already_reported] }
      if packages.length > 0
        if print_packages.length > 0
          print "\nPackage end of support before #{date_str}:\n"
          print_packages.sort_by.with_index { |p, idx| [p[:eol], idx] }.each do |p|
            print_package(p)
          end
        else
          print "\nNo packages with end of support different from product.\n"
        end
      else
        print "\nNo packages whose support ends before #{date_str}.\n"
      end
      print_lifecycle_url()

      save_report(save_fn, products, dif_packages) if save_fn

      @retval = 1 if print_products.length == 0 && print_packages.length == 0
    end

    def exit_val()
      exit @retval
    end

  end
end

# handle all times in UTC (bsc#1143453)
ENV['TZ'] = "UTC"

options = {}

OptionParser.new do |opts|
  opts.banner = "Usage: zypper lifecycle [ -v | --verbose ] [--save <file>] [ --diff <file> ] --days N | --date <date> | <package> ..."

  opts.on("--days N", Integer, "Show packages/products whose support ends in N days (from now)") do |d|
    options[:date] = Time.now + d * 60 * 60 * 24
  end
  opts.on("--date YYYY-MM-DD", Time, "Show packages/products whose support ends before the specified date") do |d|
    options[:date] = d
  end
  opts.on("--save <file>", String, "Save report to file") do |f|
    options[:save] = f
  end
  opts.on("--diff <file>", String, "Report only differences from previouly saved report") do |f|
    options[:diff] = f
  end
  opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
    options[:verbose] = v
  end
end.parse!

lifecycle = SUSE::Lifecycle.new(options[:verbose])
lifecycle.load_zypper_data
lifecycle.load_lifecycle_data
lifecycle.solve_package_eol

if options[:date]
  lifecycle.load_already_reported(options[:diff]) if options[:diff]
  lifecycle.report_deadline(options[:date], options[:save])
elsif ARGV.empty?
  lifecycle.load_already_reported(options[:diff]) if options[:diff]
  lifecycle.report(options[:save])
else
  lifecycle.report_packages(ARGV)
end


lifecycle.exit_val
