# encoding: UTF-8
#
# logic.rb: contains most logic from original apt-listbugs.
#
# Copyright (C) 2002       Masato Taruishi <taru@debian.org>
# Copyright (C) 2006-2008  Junichi Uekawa <dancer@debian.org>
# Copyright (C) 2007       Famelis George <famelis@otenet.gr>
# Copyright (C) 2008-2019  Francesco Poli <invernomuto@paranoici.org>
# Copyright (C) 2009-2010  Ryan Niebur <ryan@debian.org>
# Copyright (C) 2013       Google Inc
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; either version 2 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License with
#  the Debian GNU/Linux distribution in file /usr/share/common-licenses/GPL-2;
#  if not, write to the Free Software Foundation, Inc., 59 Temple Place,
#  Suite 330, Boston, MA  02111-1307  USA

require 'getoptlong'
require 'debian'
require 'unicode'
require 'aptlistbugs/debian/bts'
require 'thread'
require 'tempfile'
require 'gettext'
require 'rss/maker'
require 'fcntl'
require 'aptlistbugs/debian/apt_preferences'
require 'open3'

include GetText


class AppConfig
  QUERYBTS = "/usr/bin/querybts"
  SENSIBLE_BROWSER = "/usr/bin/sensible-browser"
  XDG_OPEN = "/usr/bin/xdg-open"
  WWW_BROWSER = "/usr/bin/www-browser"
  S6_SETUIDGID = "/usr/bin/s6-setuidgid"

  def usage
    $stderr.print _("Usage: "), File.basename($0),
      _(" [options] <command> [arguments]"),
      "\n",
      _("Options:\n"),
      # TRANSLATORS: the colons (:) in the following strings are vertically aligned, please keep their alignment consistent
      # TRANSLATORS: the \"all\" between quotes should not be translated
      sprintf(_(" -s <severities>  : Filter bugs by severities you want to see\n                    (or \"all\" for all)\n                    [%s].\n"), @severity.join(',')),
      _(" -T <tags>        : Filter bugs by tags you want to see.\n"),
      sprintf(_(" -S <states>      : Filter bugs by pending-state categories you want to see\n                    [%s].\n"), @stats.join(',')),
      _(" -B <bug#>        : Filter bugs by number, showing only the specified bugs.\n"),
      _(" -D               : Show downgraded packages, too.\n"),
      sprintf(_(" -H <hostname>    : Hostname of Debian Bug Tracking System [%s].\n"), @hostname),
      sprintf(_(" -p <port>        : Port number of the server [%s].\n"), @port),
      sprintf(_(" -P <priority>    : Pin-Priority value [%s].\n"), @pin_priority),
      _(" -E <title>       : Title of RSS output.\n"),
      _(" -q               : Don't display progress bar.\n"),
      _(" -C <apt.conf>    : Additional apt.conf file to use.\n"),
      _(" -F               : Automatically pin all buggy packages.\n"),
      _(" -N               : Never automatically pin packages.\n"),
      _(" -y               : Assume yes for all questions.\n"),
      _(" -n               : Assume no for all questions.\n"),
      _(" -a               : Assume the default reply for all questions.\n"),
      _(" -d               : Debug.\n"),
      _(" -h               : Display this help and exit.\n"),
      _(" -v               : Show version number and exit.\n"),
      _("Commands:\n"),
      _(" apt              : Apt mode.\n"),
      _(" list <pkg...>    : List bug reports of the specified packages.\n"),
      _(" rss <pkg...>     : List bug reports of the specified packages in RSS.\n"),
      _("See the manual page for the long options.\n")
  end

  def initialize
    @severity = ["critical", "grave", "serious"]
    @tag = nil
    @stats = ["pending", "forwarded", "pending-fixed", "fixed", "done"]
    # TRANSLATORS: the following six strings refer to a plural quantity of bugs
    # TRANSLATORS: please note that "Outstanding" means "unresolved", not "exceptional"
    @statmap = [["pending", _("Outstanding")],
                ["forwarded", _("Forwarded")],
                ["pending-fixed", _("Pending Upload")],
                ["fixed", _("Fixed in NMU")],
                ["absent", _("From other Branch")],
                ["done", _("Resolved in some Version")]]
    @fbugs = nil
    @show_downgrade = false
    @hostname = "bugs.debian.org"
    @port = 80
    @querystep = 200
    @parsestep = 200
    @quiet = false
    @command = nil
    @parser = nil
    @querybts = nil
    @browser = nil
    @cmd_prefix = ""
    o, e, s = Open3.capture3("logname")
    @ext_user = ""
    if s.success? && o != nil
      @ext_user = o.chomp
    end
    if @ext_user.empty?
      # if the user's login name cannot be determined,
      # act as if root privileges could not be dropped
      @ext_user = "root"
    end

    @ignore_bugs = read_ignore_bugs("/etc/apt/listbugs/ignore_bugs")
    @system_ignore_bugs = read_ignore_bugs("/var/lib/apt-listbugs/ignore_bugs")
    @ignore_bugs.each { |bug|
      @system_ignore_bugs.add(bug, false)
    }
    @frontend = ConsoleFrontend.new( self )
    @pin_priority = "30000"
    @apt_conf = nil

    @yes = nil
    @default_reply = nil
    @force_pin = nil

    @arrow = "->"
    if Locale.charset == "UTF-8"
      @arrow = "→"
    end
  end

  attr_accessor :severity, :stats, :quiet, :title
  attr_accessor :show_downgrade, :hostname, :tag, :fbugs, :querystep, :parsestep
  attr_accessor :frontend, :pin_priority, :yes, :ignore_regexp, :force_pin
  attr_accessor :default_reply
  attr_reader :command, :parser, :querybts, :ignore_bugs, :system_ignore_bugs
  attr_reader :browser, :arrow, :cmd_prefix, :ext_user

  def parse_options
    all_severities = ["critical","grave","serious","important","normal","minor","wishlist"]
    if /sev_list='(.*)'/ =~ `apt-config #{@apt_conf} shell sev_list AptListbugs::Severities`
      if $1 == "all"
        @severity = all_severities
      else
        @severity = $1.split(',')
      end
    end

    if /qb='(.*)'/ =~ `apt-config #{@apt_conf} shell qb AptListbugs::QueryStep`
      @querystep = $1.to_i if $1.to_i > 0
    end

    if /qb='(.*)'/ =~ `apt-config #{@apt_conf} shell qb AptListbugs::ParseStep`
      @parsestep = $1.to_i if $1.to_i > 0
    end

    opt_parser = GetoptLong.new
    opt_parser.set_options(['--help', '-h', GetoptLong::NO_ARGUMENT],
                           ['--severity', '-s', GetoptLong::REQUIRED_ARGUMENT],
                           ['--version', '-v', GetoptLong::NO_ARGUMENT],
                           ['--tag', '-T', GetoptLong::REQUIRED_ARGUMENT],
                           ['--stats', '-S', GetoptLong::REQUIRED_ARGUMENT],
                           ['--bugs', '-B', GetoptLong::REQUIRED_ARGUMENT],
                           ['--show-downgrade', '-D', GetoptLong::NO_ARGUMENT],
                           ['--hostname', '-H', GetoptLong::REQUIRED_ARGUMENT],
                           ['--port', '-p', GetoptLong::REQUIRED_ARGUMENT],
                           ['--pin-priority', '-P', GetoptLong::REQUIRED_ARGUMENT],
                           ['--title', '-E', GetoptLong::REQUIRED_ARGUMENT],
                           ['--quiet', '-q', GetoptLong::NO_ARGUMENT],
                           ['--aptconf', '-C', GetoptLong::REQUIRED_ARGUMENT],
                           ['--force-pin', '-F', GetoptLong::NO_ARGUMENT],
                           ['--force-no-pin', '-N', GetoptLong::NO_ARGUMENT],
                           ['--force-yes', '-y', GetoptLong::NO_ARGUMENT],
                           ['--force-no', '-n', GetoptLong::NO_ARGUMENT],
                           ['--force-default', '-a', GetoptLong::NO_ARGUMENT],
                           ['--debug', '-d', GetoptLong::NO_ARGUMENT]
                           );

    begin
      opt_parser.each_option { |optname, optargs|
        case optname
        when '--help'
          usage
          exit 0
        when '--version'
          puts $VERSION
          exit 0
        when '--severity'
          case optargs
          when "all"
            @severity = all_severities
          else
            @severity = optargs.split(',')
          end
        when '--tag'
          @tag = optargs.split(',')
        when '--stats'
          @stats = optargs.split(',')
        when '--bugs'
          @fbugs = optargs.split(',')
        when '--show-downgrade'
          @show_downgrade = true
        when '--hostname'
          @hostname = optargs
        when '--port'
          @port = optargs.to_i
        when '--pin-priority'
          @pin_priority = optargs
        when '--title'
          @title = optargs
        when '--quiet'
          @quiet = true
        when '--aptconf'
          @apt_conf = " -c " + optargs
        when '--debug'
          $DEBUG = 1
        when '--force-yes'
          @yes = true
          @default_reply = nil
        when '--force-no'
          @yes = false
          @default_reply = nil
        when '--force-default'
          @yes = nil
          @default_reply = true
        when '--force-pin'
          @force_pin = true
        when '--force-no-pin'
          @force_pin = false
        end
      }
    rescue GetoptLong::AmbiguousOption, GetoptLong::NeedlessArgument,
        GetoptLong::MissingArgument, GetoptLong::InvalidOption
      usage
      exit 1
    end

    if ! $stdout.isatty
      @quiet = true
      @yes = false if @yes.nil?
      @force_pin = true if @force_pin.nil?
    else
      @force_pin = false if @force_pin.nil?
    end

    # warn the user about unknown (possibly misspelled) severities
    (@severity - all_severities).each { |unrec|
      # TRANSLATORS: "W: " is a label for warnings; you may translate it with a suitable abbreviation of the word "warning"
      $stderr.puts _("W: ") + sprintf(_("Unrecognized severity '%s' will be ignored by the Debian BTS."), unrec)
    }

    @title = sprintf(_("Bugs of severity %s"), @severity.join(', ')) if ! @title

    # http_proxy sanity check
    if ENV["HTTP_PROXY"] != nil && ENV["http_proxy"] == nil
      $stderr.puts _("W: ") + _("sanity check failed: environment variable http_proxy is unset and HTTP_PROXY is set.")
    end

    # enable proxy for SOAP
    if ENV["http_proxy"] != nil && ENV["soap_use_proxy"] != "on"
      ENV["soap_use_proxy"] = "on"
    end

    # proxy settings in apt.conf
    if /http_proxy='(.*)'/ =~ `apt-config #{@apt_conf} shell http_proxy acquire::http::proxy`
      puts "proxy configuration from apt.conf: #{$1}" if $DEBUG
      if $1 == 'DIRECT' || $1 == ''
        puts "Disabling proxy due to DIRECT, or empty string" if $DEBUG
        ENV.delete("http_proxy")
        ENV.delete("soap_use_proxy")
      else
        ENV["http_proxy"] = $1
        ENV["soap_use_proxy"] = "on"
      end
    end
    if /proxy_detect='(.*)'/ =~ `apt-config #{@apt_conf} shell proxy_detect acquire::http::proxy-auto-detect`
      puts "auto proxy detect command from apt.conf: #{$1}" if $DEBUG
      if FileTest.executable?("#{$1}")
        detected_proxy = `#{$1}`.chomp
        puts "auto detected proxy: #{detected_proxy}" if $DEBUG
        if /^http/ =~ detected_proxy
          ENV["http_proxy"] = detected_proxy
          ENV["soap_use_proxy"] = "on"
        else
          puts "Ignoring auto detected proxy not beginning with 'http'" if $DEBUG
        end
      else
        STDERR.puts _("W: ") + sprintf(_("Cannot execute auto proxy detect command %s"), $1)
      end
    end
    if /http_proxy='(.*)'/ =~ `apt-config #{@apt_conf} shell http_proxy acquire::http::proxy::bugs.debian.org`
      puts "proxy configuration from apt.conf, specific for bugs.debian.org: #{$1}" if $DEBUG
      if $1 == 'DIRECT'
        puts "Disabling proxy due to DIRECT" if $DEBUG
        ENV.delete("http_proxy")
        ENV.delete("soap_use_proxy")
      else
        ENV["http_proxy"] = $1
        ENV["soap_use_proxy"] = "on"
      end
    end

    # command
    command = ARGV.shift
    case command
    when nil
      STDERR.puts _("E: ") + _("You need to specify a command.")
      usage
      exit 1
    when "list"
      @command = "list"
    when "apt"
      @command = "apt"
    when "rss"
      @command = "rss"
    else
      STDERR.puts _("E: ") + _("Unknown command ") +  "'#{command}'."
      usage
      exit 1
    end

    @parser =
      Debian::BTS::Parser::SoapIndex.new(@hostname, @port)

    if FileTest.executable?("#{QUERYBTS}")
      @querybts = QUERYBTS
    end

    if FileTest.executable?("#{SENSIBLE_BROWSER}")
      @browser = SENSIBLE_BROWSER
    elsif FileTest.executable?("#{XDG_OPEN}")
      @browser = XDG_OPEN
    elsif FileTest.executable?("#{WWW_BROWSER}")
      @browser = WWW_BROWSER
    end

    setuidgid = nil
    if FileTest.executable?("#{S6_SETUIDGID}")
      setuidgid = S6_SETUIDGID
    end

    if Process.euid == 0
      # running as root
      # external programs are better run after dropping root privileges
      if @ext_user != "root"
        # running as root, after becoming root (with "su -", "sudo", ...)
        # the original user name is @ext_user
        if setuidgid != nil
          # a command to drop root privileges is available
          @cmd_prefix = "#{setuidgid} #{@ext_user} "
        else
          # no command to drop root privileges found
          @ext_user = "root"
        end
      end
    else
      # not running as root
      # external programs may be run as the effective userid
      o, e, s = Open3.capture3("whoami")
      @ext_user = ""
      if s.success? && o != nil
        @ext_user = o.chomp
      end
      # if the effective user name cannot be determined, leave it empty
    end

    if /ignore_regexp='(.*)'/ =~ `apt-config #{@apt_conf} shell ignore_regexp AptListbugs::IgnoreRegexp`
      @ignore_regexp = $1
    end
  end

  # return the descriptive name for a status
  def statmap(stat)
    r=@statmap.assoc(stat)
    if r then
      r[1]
    else
      stat
    end
  end

  def read_ignore_bugs(path)
    ignore_bugs = IgnoreBugs.new(path)
  end
end

class IgnoreBugs < Array

  @@path_mutex = {}

  def initialize(path)
    super()
    @path = path
    @@path_mutex[path] = Mutex.new if @@path_mutex[path] == nil

    if FileTest.exist?(path)
      begin
        open(path).each { |line|
          bug = line.encode(Encoding.default_external, undef: :replace, invalid: :replace)
          if /^\s*#/ =~ bug
            next
          end
          if /^\s*(\S+)/ =~ bug
            self << $1
          end
        }
      rescue Errno::EACCES
        # read-access is not possible, warn the user that the
        # file won't be taken into account
        $stderr.puts _("W: ") + sprintf(_("Cannot read from %s"), @path)
      end
    end

    @gavewritewarning = nil
  end

  def add(entry, write=true)
    if write == true
      @@path_mutex[@path].synchronize {
        begin
          open(@path, "a") { |file|
            file.puts entry
          }
        rescue Errno::EACCES
          # write-access is not possible, warn the user that the
          # file won't be updated
          if @gavewritewarning.nil?
            $stderr.puts _("W: ") + sprintf(_("Cannot write to %s"), @path)
            @gavewritewarning = true
          end
        end
      }
    end
    self << entry
  end

end

class Viewer

  def initialize(config)
    @config = config
  end

  class SimpleViewer < Viewer

    DeprecatedWarning = _("********** on_hold IS DEPRECATED. USE p INSTEAD to use pin **********")
    DeprecatedWarningHeader = "*" * Unicode.width(DeprecatedWarning)

    def initialize(config)
      super(config)
      @bugmap = {}
    end

    def view(new_pkgs, cur_pkgs, bugs)
      if display_bugs(bugs, new_pkgs.keys, cur_pkgs, new_pkgs) == false
        return true
      end

      if @config.command == "list"
        return true
      end

      answer = "n"
      hold_pkg_keys = []
      while true
        ask_str = _("Are you sure you want to install/upgrade the above packages?").dup
        if hold_pkg_keys.empty?
          ask_str << " [Y/n/?/...]"
        else
          ask_str << " [N/?/...]"
        end
        if @config.force_pin
          a = "p"
        else
          if @config.default_reply
            a = ""
          else
            if @config.yes.nil?
              a = @config.frontend.ask ask_str
            else
              a = "y" if @config.yes
              a = "n" if ! @config.yes
            end
          end
        end
        if a.nil?
          a = "got nil!"
          @config.frontend.puts ""
        end
        if a == ""
          if hold_pkg_keys.empty?
            answer = "y"
          else
            answer = "n"
          end
        else
          answer = a.downcase
        end
        case answer

        when "y"
          return true if hold_pkg_keys.empty?

        when "a"
          if hold_pkg_keys.empty?
            bugs.each { |bug|
              if ! @config.system_ignore_bugs.include?(bug.bug_number)
                @config.system_ignore_bugs.add(bug)
                @config.system_ignore_bugs.add(bug.bug_number)
              end
            }
            return true
          end

        when "n"
          return false

        when /^(#|b)?(\d+)$/
          if @config.querybts != nil
            bugnum = nil
            if $1 == "b"
              if @bugmap.has_key?($2)
                bugnum = @bugmap[$2]
              else
                @config.frontend.puts sprintf(_("%s is unknown"), "b#{$2}")
              end
            else
              bugnum = $2
            end
            if ! bugnum.nil?
              puts "Invoking querybts for ##{bugnum}" if $DEBUG
              querybtscommandline = "#{@config.cmd_prefix}#{@config.querybts} -u text #{bugnum}"
              if system(querybtscommandline)
                puts "successfully invoked querybts" if $DEBUG
              else
                $stderr.puts _("W: ") + _("Failed to invoke querybts.")
                $stderr.puts " #{querybtscommandline}"
              end
            end
          else
            @config.frontend.puts sprintf(_("You must install the reportbug package to be able to do this"))
          end

        when /^i\s+(b)?(\d+)$/
          bugnum = nil
          if $1 == "b"
            if @bugmap.has_key?($2)
              bugnum = @bugmap[$2]
            else
              @config.frontend.puts sprintf(_("%s is unknown"), "b#{$2}")
            end
          else
            bugnum = $2
          end
          if ! bugnum.nil?
            if ! @config.system_ignore_bugs.include?(bugnum)
              bug = @config.parser.parse_bug(bugnum)
              @config.system_ignore_bugs.add(bug)
              @config.system_ignore_bugs.add(bugnum)
              Factory::BugsFactory.delete_ignore_bugs(bugs)
              # TRANSLATORS: "ignored" refers to one singular bug
              @config.frontend.puts sprintf(_("%s ignored"), bugnum)
            else
              # TRANSLATORS: "ignored" refers to one singular bug
              @config.frontend.puts sprintf(_("%s already ignored"), bugnum)
            end
          end

        when "r"
          display_bugs(bugs, new_pkgs.keys - hold_pkg_keys, cur_pkgs, new_pkgs)

        when /^(h|p)\s+(.+)$/
          cmd = $1
          if cmd == "h"
            @config.frontend.puts DeprecatedWarningHeader
            @config.frontend.puts DeprecatedWarning
            @config.frontend.puts DeprecatedWarningHeader
          end
          pkg_keys = $2.split(/\s+/)
          if cmd == "h"
            h = on_hold(pkg_keys)
          else
            h = pinned(pkg_keys, cur_pkgs, bugs)
          end
          hold_pkg_keys.concat(h) if h != nil

        when "w"
          puts bugs if $DEBUG
          if @config.browser != nil
            display_bugs_as_html(bugs, cur_pkgs, new_pkgs)
          else
            @config.frontend.puts sprintf(_("You must install a web browser package to be able to do this"))
          end

        when /^(h|p)$/
          cmd = $1
          if cmd == "h"
            @config.frontend.puts DeprecatedWarningHeader
            @config.frontend.puts DeprecatedWarning
            @config.frontend.puts DeprecatedWarningHeader
          end
          pkgs = {}
          if cmd == "p"
            prev_pinned_pkgs = {}
            begin
              p = Debian::AptPreferences.new
              p.pins.each { |pin|
                prev_pinned_pkgs[pin["Package"]] = 1
              }
            rescue
              # something went wrong while parsing preferences: assume no pins
            end
            bugs.each { |bug|
              if ! prev_pinned_pkgs.has_key?(bug.pkg_key)
                pkgs[bug.pkg_key] = 1
              end
            }
          else
            bugs.each { |bug|
              pkgs[bug.pkg_key] = 1
            }
          end
          if pkgs.size != 0
            @config.yes = true if @config.force_pin
            if @config.frontend.yes_or_no? ngettext(
             # TRANSLATORS: %{plist} is a comma-separated list of %{npkgs} packages to be pinned or put on hold.
             "The following %{npkgs} package will be pinned or on hold:\n %{plist}\nAre you sure?",
             "The following %{npkgs} packages will be pinned or on hold:\n %{plist}\nAre you sure?",
             pkgs.size) % {:npkgs => pkgs.size,
                           :plist => pkgs.keys.join(', ')}
              if cmd == "h"
                h = on_hold(pkgs.keys)
              else
                h = pinned(pkgs.keys, cur_pkgs, bugs)
              end
            end
            hold_pkg_keys.concat(h) if h != nil
          else
            @config.frontend.puts sprintf(_("All selected packages are already pinned or on hold. Ignoring %s command."), cmd)
          end
          return false if @config.force_pin

        else
          if hold_pkg_keys.empty?
            @config.frontend.puts "" +
              # TRANSLATORS: the dashes (-) in the following strings are vertically aligned, please keep their alignment consistent
              _("     y     - continue the APT installation, but do not mark the bugs\n             as ignored.\n") +
              _("     a     - continue the APT installation and mark all the above bugs\n             as ignored.\n")
          end
          @config.frontend.puts "" +
            _("     n     - stop the APT installation.\n")
          if @config.querybts != nil
            @config.frontend.puts "" +
            # TRANSLATORS: %{prog} is the name of a program, %{user} is a user name.
              _("   <num>   - query the specified bug number\n             (uses %{prog} as user %{user}).\n") %
                {:prog => File.basename(@config.querybts),
                 :user => @config.ext_user} +
              _("  #<num>   - same as <num>.\n") +
              _("   b<id>   - same as <num>, but query the bug identified by <id>.\n")
          end
          @config.frontend.puts "" +
            _("     r     - redisplay bug lists.\n") +
            _(" p <pkg..> - pin pkgs (restart APT session to enable).\n") +
            _(" p         - pin all the above pkgs (restart APT session to enable).\n") +
            _(" i <num>   - mark bug number <num> as ignored.\n") +
            _(" i b<id>   - mark the bug identified by <id> as ignored.\n") +
            _("     ?     - print this help.\n")
          if @config.browser != nil
            @config.frontend.puts _("     w     - display bug lists in HTML\n             (uses %{prog} as user %{user}).\n") %
              {:prog => File.basename(@config.browser),
               :user => @config.ext_user}
          end
        end
      end
    end

    def bugs_of_pkg( bugs, pkg_key )
      b = []
      bugs.each { |bug|
        b << bug if bug.pkg_key == pkg_key
      }
      b
    end

    def pinned(pkg_keys, cur_pkgs, bugs)
      holdstr = ""
      pinned_pkg_keys = []
      pkg_keys.each { |pkg_key|
        my_bugs = bugs_of_pkg( bugs, pkg_key )
        if my_bugs.size > 0 || @config.frontend.yes_or_no?(sprintf(
         _("None of the above bugs is assigned to package %s\nAre you sure you want to pin it?"),
         pkg_key))
          pin_ver = "0.no.version"
          pin_pri = @config.pin_priority
          if cur_pkgs[pkg_key] != nil
            pin_ver = cur_pkgs[pkg_key]['version']
          else
            pin_ver = "*"
            pin_pri = "-30000"
          end
          pinned_pkg_keys << pkg_key
          holdstr << "\nExplanation: Pinned by apt-listbugs at #{Time.now}"
          my_bugs.each { |bug|
            holdstr << "\nExplanation:   ##{bug.bug_number}: #{bug.desc}"
          }
          holdstr << "\nPackage: #{pkg_key}\nPin: version #{pin_ver}"
          holdstr << "\nPin-Priority: #{pin_pri}\n"
        end
      }
      $stderr.puts holdstr if $DEBUG
      if holdstr != ""
        filename = "/etc/apt/preferences.d/apt-listbugs"
        begin
          File.open(filename, "a") { |io|
            io.puts holdstr
            # TRANSLATORS: %{packgl} is a list of packages, %{filenm} is a file name.
            @config.frontend.puts _("%{packgl} pinned by adding Pin preferences in %{filenm}. Restart APT session to enable") %
              {:packgl => pinned_pkg_keys.join(' '),
               :filenm => filename}
            return pinned_pkg_keys
          }
        rescue Errno::EACCES
          # write-access is not possible, warn the user that the
          # file won't be updated
          $stderr.puts _("W: ") + sprintf(_("Cannot write to %s"), filename)
        end
      end
      return nil
    end

    def on_hold (pkg_keys)
      holdstr = ""
      pkg_keys.each { |pkg_key|
        holdstr << "#{pkg_key} hold\n"
      }
      if system("echo '#{holdstr}' | dpkg --set-selections")
        @config.frontend.puts sprintf(_("%s held. Restart APT session to enable"), pkg_keys.join(' '))
        return pkg_keys
      end
      return nil
    end

    def display_bugs(bugs, pkg_keys, cur_pkgs, new_pkgs)
      # routine to display every bug that is available and relevant

      bugs_statistics = {}
      @config.stats.each { |stat|
        @config.severity.each { |severity|
          pkg_keys.each { |pkg_key|
            p_bug_numbers = []
            bug_exist = 0
            bugs_statistics[pkg_key] = 0 unless bugs_statistics[pkg_key]
            bugs.each_by_category(pkg_key, severity, stat) { |bug|
              next if p_bug_numbers.include?(bug.bug_number)
              bugs_statistics[pkg_key] += 1
              p_bug_numbers << bug.bug_number
              if bug_exist == 0
                # TRANSLATORS: %{sevty} is the severity of some of the bugs found for package %{packg}.
                buf = _("%{sevty} bugs of %{packg} (") % {:sevty => severity,
                                                          :packg => pkg_key}
                buf += "#{cur_pkgs[pkg_key]['version']} " if cur_pkgs[pkg_key] != nil
                buf += "#{@config.arrow} #{new_pkgs[pkg_key]['version']}) <#{@config.statmap(bug.stat)}>"
                @config.frontend.puts buf
                bug_exist = 1
              end
              if bug.id.nil?
                bug.id = @bugmap.length.next
                @bugmap[bug.id.to_s] = bug.bug_number
              end
              bug_str = " b#{bug.id} - ##{bug.bug_number} - #{bug.desc}"
              # TRANSLATORS: "Found" refers to one singular bug
              bug_str += sprintf(_(" (Found: %s)"), "#{bug.found}") if ( ! bug.found.nil? ) && $DEBUG
              # TRANSLATORS: "Fixed" refers to one singular bug
              bug_str += sprintf(_(" (Fixed: %s)"), "#{bug.fixed}") if ! bug.fixed.nil?
              @config.frontend.puts bug_str
              if bug.mergeids.size > 0
                # TRANSLATORS: "Merged" refers to one singular bug
                bug_str =  _("   Merged with:").dup()
                bug.mergeids.each { |m|
                  bug_str << " #{m}"
                  p_bug_numbers << m
                }
                @config.frontend.puts bug_str
              end
            }
          }
        }
      }
      stat_str_ary = []
      bugs_statistics.each_pair { |pkg_key, num|
        if num > 0
          # TRANSLATORS: %{nbugs} is the number of bugs found for package %{packg}.
          buf = ngettext("%{packg}(%{nbugs} bug)",
                         "%{packg}(%{nbugs} bugs)", num) % {:packg => pkg_key,
                                                            :nbugs => num}
          stat_str_ary << buf
        end
      }
      if stat_str_ary.size > 0
        @config.frontend.puts _("Summary:\n ") + stat_str_ary.join(', ')
        return true
      else
        return false
      end
    end

    def each_state_table(o, bugs, stats)
      stats.each { |stat|
        sub = bugs.sub("stat", stat)
        if sub.size > 0
          o.print "\n    <table class=\"bug_table\" summary="
          # TRANSLATORS: this is a summary description of the structure of a table (for accessibility)
          o.print _("The top row describes the meaning of the columns; the other rows describe bug reports, one per row").encode(:xml => :attr)
          o.print ">\n"
          o.puts "      <caption>\n        " + @config.statmap(stat).encode(:xml => :text)
          o.puts "      </caption>"
          o.puts "\n      <tr class=\"header\">"
          o.puts "        <th class=\"pkg\">" + _("package").encode(:xml => :text) + "</th>"
          o.puts "\n        <th>" + _("version change").encode(:xml => :text) + "</th>"
          o.puts "\n        <th>" + _("severity").encode(:xml => :text) + "</th>"
          o.puts "\n        <th>" + _("bug number").encode(:xml => :text) + "</th>"
          o.puts "\n        <th class=\"desc\">" + _("description").encode(:xml => :text) + "</th>"
          o.puts "      </tr>"
          yield sub
          o.puts "    </table>\n    <hr class=\"nocss\" />"
        end
      }
    end

    def display_bugs_as_html(bugs, cur_pkgs, new_pkgs)
      tmp = Tempfile.new(["apt-listbugs", ".html"])
      tmp.chmod(0644)
      tmp.puts "<?xml version=\"1.0\" encoding=\"#{Locale.charset}\"?>"
      tmp.puts "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\""
      tmp.puts "    \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">"
      tmp.puts "\n<html xmlns=\"http://www.w3.org/1999/xhtml\">"
      tmp.puts "<head>"
      tmp.puts "  <meta http-equiv=\"Content-type\" content=\"text/html; charset=#{Locale.charset}\" />"
      tmp.puts "  <meta name=\"generator\" content=\"apt-listbugs\" />"
      tmp.puts "\n  <title>" + _("Relevant bugs for your upgrade").encode(:xml => :text) + "</title>"
      tmp.puts "  <link href=\"/etc/apt/listbugs/bug-list.css\" rel=\"stylesheet\" type=\"text/css\" />"
      tmp.puts "</head>\n\n<body>\n  <div id=\"page_title\">"
      tmp.puts "    <h1 class=\"title\">" + _("Relevant bugs for your upgrade").encode(:xml => :text) + "</h1>"
      tmp.puts "\n    <h5 class=\"generated\">" + _("by apt-listbugs").encode(:xml => :text) + "</h5>"
      tmp.puts "  </div>\n\n  <div id=\"page_body\">"
      tmp.puts "    <hr class=\"nocss\" />"

      each_state_table(tmp, bugs, @config.stats) { |bugs|
        p_bug_numbers = []
        bugs.each { |bug|
          next if p_bug_numbers.include?(bug.bug_number)
          p_bug_numbers << bug.bug_number
          p_bug_numbers += bug.mergeids
          pkg_key = bug.pkg_key
          tmp.puts "\n      <tr class=#{bug.severity.encode(:xml => :attr)}>"
          tmp.puts "        <td class=\"pkg\">#{pkg_key.encode(:xml => :text)}</td>"
          tmp.print "\n        <td>"
          tmp.print "#{cur_pkgs[pkg_key]['version'].encode(:xml => :text)} " if cur_pkgs[pkg_key] != nil
          tmp.print "#{@config.arrow.encode(:xml => :text)} #{new_pkgs[pkg_key]['version'].encode(:xml => :text)}" if new_pkgs[pkg_key] != nil
          tmp.puts "</td>\n\n        <td>#{bug.severity.encode(:xml => :text)}</td>"
          tmp.puts "\n        <td><a href=\"http://bugs.debian.org/#{bug.bug_number}\">##{bug.bug_number}</a></td>"
          tmp.puts "\n        <td class=\"desc\">#{bug.desc.encode(:xml => :text)}</td>\n      </tr>"
        }
      }

      tmp.puts "  </div>\n</body>\n</html>"
      tmp.close

      puts "Invoking browser for #{tmp.path}" if $DEBUG
      browsercommandline = "#{@config.cmd_prefix}#{@config.browser} #{tmp.path}"
      if system(browsercommandline)
        puts "successfully invoked browser" if $DEBUG
      else
        $stderr.puts _("W: ") + _("Failed to invoke browser.")
        $stderr.puts " #{browsercommandline}"
      end
      clear_stdin
    end

    private
    def clear_stdin(parent = true)
      flags=$stdin.fcntl(Fcntl::F_GETFL)
      if parent
        while clear_stdin(false)
          nil
        end
      else
        begin
          $stdin.read_nonblock(10000000)
          return true
        rescue Errno::EAGAIN
          return false
        ensure
          $stdin.fcntl(Fcntl::F_SETFL, flags)
        end
      end
    end
  end


  class RSSViewer < Viewer

    def initialize(config)
      super(config)
    end

    def view(new_pkgs, cur_pkgs, bugs)
      rss = RSS::Maker.make("2.0") { |maker|
        maker.channel.about = ""
        maker.channel.title = @config.title
        maker.channel.description = @config.title
        maker.channel.link = "http://bugs.debian.org/"

        p_bug_numbers = []

        bugs.each { |bug|
          if @config.stats.include?( bug.stat )
            next if p_bug_numbers.include?(bug.bug_number)
            p_bug_numbers << bug.bug_number
            item = maker.items.new_item
            item.link = "http://bugs.debian.org/#{bug.bug_number}"
            item.title = "##{bug.bug_number} - #{bug.desc}"
            item.date = Time.parse("#{bug.time.year}/#{bug.time.month}/#{bug.time.day} #{bug.time.hour}:#{bug.time.min}:#{bug.time.sec}")

            buf = ""

            buf << "<ul>\n"
            buf << "<li>" + _("bug number")       + ": ##{bug.bug_number}</li>\n"
            buf << "<li>" + _("package")          + ": #{bug.pkg_key}</li>\n"
            buf << "<li>" + _("severity")         + ": #{bug.severity}</li>\n"
            buf << "<li>" + _("category of bugs") + ": #{@config.statmap(bug.stat)}</li>\n"
            buf << "<li>" + _("tags")             + ": #{bug.tags.join(',')}</li>\n" if bug.tags.size > 0

            if bug.mergeids.size > 0
              buf << "<li>" + _("   Merged with:")
              bug.mergeids.each { |id|
                url  = "http://bugs.debian.org/#{id}"
                buf << " <a href=\"#{url}\">#{id}</a>"
                p_bug_numbers << id
              }
              buf << "</li>\n"
            end
            buf << "</ul>\n"

            item.description = buf

          end
        }

      }
      @config.frontend.puts rss.to_s
    end

  end

end


module Factory

  CONCURRENCY_LEVEL = 3

  def done?(done)
    _("Done") == done
  end

  def config
      @@config
  end

  def config=(c)
      @@config = c
  end

  def create(arg, *args)
    raise _("Not Implemented")
  end

  module_function :config, :config=, :create, :done?
  public :create

  module BugsFactory
    extend Factory

    def delete_ignore_pkgs(new_pkgs)
      new_pkgs.delete_if { |pkg_key, pkg|
        config.system_ignore_bugs.include?(pkg_key)
      }
    end

    def create(ma_copies)
      bugs = Debian::Bugs.new
      retrycount = 10 # retry 10 times

      mutex = Mutex.new
      threads = []
      # TRANSLATORS: this sentence, followed by the translation of "Done" (see above) should fit in less than 79 columns to work well with default width terminals
      yield _("Retrieving bug reports..."), "0%"
      begin
        config.frontend.puts " Using QueryStep = #{config.querystep}" if $DEBUG
        config.frontend.puts " Using ParseStep = #{config.parsestep}" if $DEBUG
        # obtain the array of bugs
        bugs = config.parser.parse(ma_copies, config.querystep, config.parsestep, config.severity) { |pct|
          yield _("Retrieving bug reports..."), pct
        }
      rescue SOAP::HTTPStreamError => exception
        config.frontend.puts _(" Fail")
        config.frontend.puts " Exception: " + exception.class.to_s if $DEBUG
        $stderr.puts _("E: ") + _("HTTP GET failed")
        retrycount -= 1
        if config.frontend.yes_or_no?(_("Retry downloading bug information?")) && retrycount > 0
          config.querystep = 1 if config.querystep != 1 && config.frontend.yes_or_no?(_("One package at a time?"))
          config.parsestep = 1 if config.parsestep != 1 && config.frontend.yes_or_no?(_("One bug report at a time?"))
          retry
        end
        raise _("Exiting with error") if ! config.frontend.yes_or_no?(_("Continue the installation anyway?"), false)
        bugs = []
      rescue SOAP::EmptyResponseError => exception
        config.frontend.puts _(" Fail")
        config.frontend.puts " Exception: " + exception.class.to_s if $DEBUG
        $stderr.puts _("E: ") + _("Empty stream from SOAP")
        retrycount -= 1
        if config.frontend.yes_or_no?(_("Retry downloading bug information?")) && retrycount > 0
          config.querystep = 1 if config.querystep != 1 && config.frontend.yes_or_no?(_("One package at a time?"))
          config.parsestep = 1 if config.parsestep != 1 && config.frontend.yes_or_no?(_("One bug report at a time?"))
          retry
        end
        raise _("Exiting with error") if ! config.frontend.yes_or_no?(_("Continue the installation anyway?"), false)
        bugs = []
      rescue Exception => exception
        config.frontend.puts _(" Fail")
        config.frontend.puts " Exception: " + exception.class.to_s if $DEBUG
        config.frontend.puts _("Error retrieving bug reports from the server with the following error message:")
        config.frontend.puts _("E: ") + "#{$!}"
        if exception.kind_of? SocketError
          config.frontend.puts _("It appears that your network connection is down. Check network configuration and try again")
        else
          config.frontend.puts _("It could be because your network is down, or because of broken proxy servers, or the BTS server itself is down. Check network configuration and try again")
        end
        retrycount -= 1
        if config.frontend.yes_or_no?(_("Retry downloading bug information?")) && retrycount > 0
          config.querystep = 1 if config.querystep != 1 && config.frontend.yes_or_no?(_("One package at a time?"))
          config.parsestep = 1 if config.parsestep != 1 && config.frontend.yes_or_no?(_("One bug report at a time?"))
          retry
        end
        raise _("Exiting with error") if ! config.frontend.yes_or_no?(_("Continue the installation anyway?"), false)
        bugs = []
      end
      yield _("Retrieving bug reports..."), "100%"

      if block_given?
        yield _("Retrieving bug reports..."), _("Done")
      end
      bugs
    end

    def delete_ignore_bugs(bugs)
      # ignoring ignore_bugs
      bugs.delete_if { |bug| config.system_ignore_bugs.include?(bug.bug_number)}
    end

    def delete_regexp_bugs(bugs, regexp)
      puts "Ignoring regexp: #{regexp}" if $DEBUG
      bugs.delete_if {|bug| bug.desc =~ /#{config.ignore_regexp}/}
    end

    def delete_uninteresting_bugs(bugs)
      # ignoring all bugs but the requested ones
      bugs.delete_if { |bug| !config.fbugs.include?(bug.bug_number)}
    end

    def iterate_fixed_found_version(bts_versions, src_name)
      # iterate relevant versions, used to parsing Fixed and Found tags of BTS
      if bts_versions.nil?
        return;
      end
      bts_versions.split(" ").each { |version|
        # check each version
        case version
        when /^(.*)\/(.*)$/
          if $1 == src_name
            yield $2
          else
            # TODO: ignore this until src_name is really the source package name
            # yield nil
            yield $2
          end
        else
          yield version
        end
      }
    end

    def find_max_version_below_ver(bts_versions, new_ver, src_name)
      # find the max version from found/fixed that is below or equal to new_ver
      # data format of bts_versions:
      # space-delimited sequence of SRC_PACKAGE/VERSION or VERSION items.

      maxver=nil
      iterate_fixed_found_version(bts_versions, src_name) { |ver|
        # check each ver
        if Debian::Dpkg.compare_versions(ver, "le", new_ver) &&
            ( maxver == nil || Debian::Dpkg.compare_versions(maxver, "le", ver) )
          maxver = ver
        end
      }
      maxver
    end

    def find_min_version_above_ver(bts_versions, new_ver, src_name)
      # find the min version from found/fixed that is strictly above new_ver
      # data format of bts_versions:
      # space-delimited sequence of SRC_PACKAGE/VERSION or VERSION items.

      minver=nil
      iterate_fixed_found_version(bts_versions, src_name) { |ver|
        # check each ver
        if Debian::Dpkg.compare_versions(ver, "gt", new_ver) &&
            ( minver == nil || Debian::Dpkg.compare_versions(minver, "ge", ver) )
          minver = ver
        end
      }
      minver
    end

    def am_i_buggy(src_name, ver, fixed, found)
      # find out if this version is buggy or not depending on the
      # fixed / found arrays.

      puts " .. checking ver #{ver} against fixed:#{fixed} found:#{found}" if $DEBUG

      fixed_max_below = nil
      found_max_below = nil

      fixed_min_above = nil
      found_min_above = nil

      fixed_max_below = find_max_version_below_ver(fixed, ver, src_name) if ! fixed.nil?
      found_max_below = find_max_version_below_ver(found, ver, src_name) if ! found.nil?

      fixed_min_above = find_min_version_above_ver(fixed, ver, src_name) if ! fixed.nil?
      found_min_above = find_min_version_above_ver(found, ver, src_name) if ! found.nil?

      val=true

      if fixed_max_below == nil && found_max_below == nil
        # this is a hard thing to handle, but do some guessing here...
        # the bug was not fixed or found before this version:
        # it either means it wasn't found before this version,
        # or 'found' version info is missing from BTS
        if found_min_above == nil
          # no new version found;
          # which I guess means that the BTS is missing version info
          puts " ... no found info: I guess I am buggy" if $DEBUG
          val=true
        else
          # found_min_above is not nil, which means I may not be buggy;
          # except for a case where it's fixed before the found_min,
          # which probably means 'found' info is missing from BTS
          if fixed_min_above == nil ||
              Debian::Dpkg.compare_versions(fixed_min_above, "gt", found_min_above)
            puts " ... bug found in a later version: I guess I am not buggy" if $DEBUG
            val=false
          else
            puts " ... bug fixed in a later version before it's found again: I guess I am buggy" if $DEBUG
            val=true
          end
        end
      else if fixed_max_below == nil
             # the bug was found before (or in) this version, but fixed
             # later (or never): it means I am buggy
             puts " ... bug found in a prior version, but not yet fixed: I am buggy" if $DEBUG
             val=true
           else if found_max_below == nil || Debian::Dpkg.compare_versions(fixed_max_below, "gt", found_max_below)
                  # the bug was not found between the latest fixed version
                  # and this version: it means I am not buggy
                  puts " ... bug not found between the latest fixed version and this version: I am not buggy" if $DEBUG
                  val=false
                else
                  # the bug was indeed found between the latest fixed version
                  # and this version: it means I am buggy
                  puts " ... bug found between the latest fixed version and this version: I am buggy" if $DEBUG
                  val=true
                end
           end
      end

      val
    end

    def bug_is_irrelevant(src_name, cur_ver, new_ver, bug_number, fixed, found, bug_stat="")
      # find out if the bug number is irrelevant for this specific upgrade, from fixed/found information.
      # @return false: bug is relevant, true: bug is irrelevant, should be removed.
      val = false

      puts "Start checking: #{bug_number}" if $DEBUG

      # ignore bugs that have no fixed version info, and are closed with a XXXX-done
      if fixed.nil? && bug_stat == "done"
        puts "bug apparently closed with XXXX-done without version info" if $DEBUG
        val = true
      else if new_ver.nil?
             # no specific version was given for the package, which means that we are interested in all (non archived) bugs
             puts "no package version given" if $DEBUG
             val = false
           else if cur_ver.nil?
                  # no known installed version, which means that we want to check the new version
                  val = true if ! am_i_buggy(src_name, new_ver, fixed, found)
                else
                  # both versions are known, which means that we want to check whether the upgrade may introduce this bug into the system
                  val = true if am_i_buggy(src_name, cur_ver, fixed, found) || ( ! am_i_buggy(src_name, new_ver, fixed, found))
                end
           end
      end

      puts "in conclusion, for bug #{bug_number} comparing fixed:[#{fixed}], found:[#{found}], cur_ver:#{cur_ver} and new_ver:#{new_ver} results in removal:#{val}" if $DEBUG
      val
    end

    def delete_irrelevant_bugs (bugs, cur_pkgs, new_pkgs)
      # Ignore bugs that do not apply to the installing version.

      max=bugs.size
      step=(max/100)*10+1
      i=0
      # TRANSLATORS: this sentence, followed by the translation of "Done" (see above) should fit in less than 79 columns to work well with default width terminals
      yield _("Parsing Found/Fixed information..."), "0%"

      bugs.delete_if { |bug|
        val = false
        pkg_key = bug.pkg_key
        new_ver = nil
        cur_ver = nil
        new_ver = new_pkgs[pkg_key]["version"] if new_pkgs[pkg_key] != nil
        cur_ver = cur_pkgs[pkg_key]["version"] if cur_pkgs[pkg_key] != nil

        # treat back-ported versions as if they were the corresponding
        # official versions (this special handling is necessary until
        # we can query the BTS version tracking directly, see #694979#19,
        # and until back-ports are correctly handled by BTS version tracking)
        new_o_ver = nil
        cur_o_ver = nil
        new_o_ver = new_ver.gsub(/~bpo[0-9].*$/, '') if new_ver != nil
        cur_o_ver = cur_ver.gsub(/~bpo[0-9].*$/, '') if cur_ver != nil

        # show progress
        yield _("Parsing Found/Fixed information..."),
        "#{(i.to_f/max.to_f*100).to_i}%" if (i % step) == 0
        i += 1

        # TODO: this should actually be the source package name...
        # use the binary package name, until there is a better strategy
        src_name = new_pkgs[pkg_key]
        src_name = src_name["package"] if src_name != nil

        val = true if bug_is_irrelevant(src_name, cur_o_ver, new_o_ver,
                                        bug.bug_number, bug.fixed, bug.found, bug.stat)
        val
      }
      yield _("Parsing Found/Fixed information..."), "100%"
      yield _("Parsing Found/Fixed information..."), _("Done")
      bugs
    end

    def delete_unwanted_tag_bugs( bugs )
      puts "checking unwanted bugs: #{bugs}" if $DEBUG
      bugs.delete_if { |bug|
        val = false
        puts "#{bug}" if $DEBUG
        config.tag.each { |tag|
          if bug.tags && bug.tags.include?( tag )
            puts "#{bug} has {tag}" if $DEBUG
          else
            val = true
          end
        }
        val
      }
    end

    module_function :delete_ignore_pkgs, :create, :delete_ignore_bugs,
    :delete_uninteresting_bugs,
    :delete_regexp_bugs,
    :bug_is_irrelevant,
    :am_i_buggy,
    :delete_irrelevant_bugs, :delete_unwanted_tag_bugs,
    :find_max_version_below_ver,
    :find_min_version_above_ver,
    :iterate_fixed_found_version

  end

end

class ConsoleFrontend

  def initialize( config )
    @old = ""
    @config = config
  end

  def progress(msg, val)
    $stderr.print "\r"
    $stderr.print " " * Unicode.width(@old)
    $stderr.print "\r"
    @old = "#{msg} #{val}"
    $stderr.print @old
    $stderr.flush
    $stderr.puts "" if Factory.done?(val)
  end

  def puts(msg)
    $stdout.puts msg
  end

  def ask(msg)
    $stdout.print "#{msg} "
    $stdout.flush
    line = nil
    line = $stdin.gets
    if line != nil
      line.chomp!
    end
    return line
  end

  def yes_or_no?(msg, default = true)
    return @config.yes if ! @config.yes.nil?
    return default if @config.default_reply
    while true
      msgyn = "#{msg}"
      if default == true
        msgyn << " [Y/n]"
      else
        msgyn << " [y/N]"
      end
      a = ask msgyn
      if a == ""
        return default
      elsif a == "Y" || a == "y"
        return true
      elsif a == "N" || a == "n"
        return false
      elsif a.nil?
        $stdout.print "\n"
      end
    end
  end

  def close
  end
end

### ;;;
### Local Variables: ***
### dancer-test-run-chdir: "../.." ***
### End: ***
