#!/usr/local/bin/ruby
# -*- ruby -*-
#
# Copyright (c) 2000-2001 Akinori MUSHA
#
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.
#

RCS_ID = %q$Idaemons: /home/cvs/pkgtools/bin/pkgdb,v 1.38 2002/03/29 16:06:58 knu Exp $
RCS_REVISION = RCS_ID.split[2]
MYNAME = File.basename($0)

require "optparse"
require "pkgtools"

COLUMNSIZE = 28
NEXTLINE = "\n%*s" % [5 + COLUMNSIZE, '']

def init_global
  $fix_db = false
  $force = false
  $noconfig = false
  #$sanity_check = true
  $update_db = false
end

def main(argv)
  usage = <<-"EOF"
usage: #{MYNAME} [-hfFquv] [-c pkgname] [-o pkgname] [-s /old_pkgname/new_pkgname/] [file ...]
    EOF

  banner = <<-"EOF"
#{MYNAME} rev.#{RCS_REVISION}

#{usage}
  EOF

  dry_parse = true

  OptionParser.new(banner, COLUMNSIZE) do |opts|
    opts.def_option("-h", "--help", "Show this message") {
      print opts
      exit 0
    }

    opts.def_option("-c", "--collate=PKGNAME", "Show files installed by the given packge#{NEXTLINE}that have been overwritten by other packages") {
      |pkgname|
      pkgname = $pkgdb.strip(pkgname, true)

      begin
	pkg = PkgInfo.new(pkgname)

	pkg.files.each do |path|
	  owners = $pkgdb.which_m(path) or
	    raise PkgDB::DBError, "The file #{path} is not properly recorded as installed"

	  i = owners.index(pkgname) or
	    raise PkgDB::DBError, "The file #{path} is not properly recorded as installed by #{pkgname}"

	  if i != owners.size - 1
	    print "#{path}: "
	    print "overwritten by: " if $verbose
	    puts owners[(i + 1)..-1].join(' ')
	  end
	end
      rescue => e
	STDERR.puts e.message
      end unless dry_parse
    }

    opts.def_option("-f", "--force", "Force;#{NEXTLINE}Specified with -u, update database#{NEXTLINE}regardless of timestamps#{NEXTLINE}Specified with -F, fix held packages too") {
      |$force|
    }

    opts.def_option("-F", "--fix", "Fix the package database interactively") {
      |$fix_db|
    }

    opts.def_option("-o", "--origin=PKGNAME", "Look up the origin of the given package") {
      |pkgname|
      pkgname = $pkgdb.strip(pkgname, true)

      unless dry_parse
	print pkgname, ": " if $verbose
	puts($pkgdb.origin(pkgname) || '?')
      end
    }

#    opts.def_option("-O", "--omit-check", "Omit sanity checks for dependencies.") {
#      $sanity_check = false
#    }

    opts.def_option("-q", "--noconfig", "Do not read pkgtools.conf") {
      |$noconfig|
    }

    opts.def_option("-s", "--substitute=/OLD/NEW/", "Substitute all the dependencies recorded#{NEXTLINE}as OLD with NEW") {
      |expr|
      if expr.empty?
	warning_message "Illegal expression: " + expr
	print opts
	exit 64
      end

      break if dry_parse

      sep = expr.slice!(0,1)

      oldpkgname, newpkgname = expr.split(sep)

      if $verbose
	progress_message "Replacing dependencies: #{oldpkgname} -> #{newpkgname}"
      end

      update_pkgdep(oldpkgname, newpkgname)
    }

    opts.def_option("-u", "--update", "Update the package database") {
      |$update_db|
    }

    opts.def_option("-v", "--verbose", "Be verbose") {
      |$verbose|
    }

    opts.def_tail_option '
Environment Variables [default]:
    PKGTOOLS_CONF            configuration file [$PREFIX/etc/pkgtools.conf]
    PKG_DBDIR                packages DB directory [/var/db/pkg]
    PORTSDIR                 ports directory [/usr/ports]
    PORTS_DBDIR              ports db directory [$PORTSDIR]
    PORTS_INDEX              ports index file [$PORTSDIR/INDEX]'

    if argv.empty?
      print opts
      return 0
    end

    begin
      init_global

      rest = opts.order(*argv)

      unless $noconfig
	init_global
	load_config
      else
	argv = rest
      end

      dry_parse = false

      opts.order!(argv)

      if $update_db
	$pkgdb.update_db($force)
	$pkgdb.open_db	# let it check the DB version
      end

      if $fix_db
	fix_db()
      end

      list = []
      
      opts.order(*argv) do |arg|
	if File.exist?(arg)
	  path = arg
	else
	  path = `which #{arg}`.chomp

	  if not File.exist?(path)
	    STDERR.puts "#{arg}: not found"
	    next
	  end
	end

	print "#{path}: " if $verbose

	if owners = $pkgdb.which_m(path)
	  puts owners.join(' ')
	else
	  puts '?'
	end
      end
    rescue OptionParser::ParseError => e
      STDERR.puts "#{MYNAME}: #{e}", usage
      exit 64
    rescue => e
      STDERR.puts e.message
      exit 1
    end
  end

  0
end

def fix_db
  if not File.owned?($pkgdb_dir)
    if $force
      warning_message "You do not own #{$pkgdb_dir}. (proceeding anyway)"
    else
      warning_message "You do not own #{$pkgdb_dir}. (use -f to force)"
      exit 1
    end
  end

  require "readline" unless defined?(Readline)

  stty_sane

  $pkgnames = $pkgdb.installed_pkgs

  $req_hash = {}	# a hash of pkgname => { dependent1 => true , ... } pairs
  $fix_hash = {}	# a hash of pkgname => ans pairs
  $all_list = []	# an array of pkgnames, with which a user answered "all"

  fix_db_phase1()
  fix_db_phase2()
end

def fix_db_phase1()
  # fix missing or stale origins
  org_hash = {}		# a hash of origin => [pkg1, pkg2, ...] pairs

  deleted = []

  $pkgnames.each do |pkgname|
    puts "Checking the origin of #{pkgname}"

    pkg = PkgInfo.new(pkgname)

    case origin = fix_origin(pkg)
    when nil
      deleted << pkgname
    when false
      # skipped
    else
      if org_hash.key?(origin)
	org_hash[origin] << pkg
      else
	org_hash[origin] = [pkg]
      end
    end
  end

  $pkgnames -= deleted

  # fix origin duplicates
  puts "Checking for origin duplicates"

  fix_duplicates(org_hash).each do |pkg|
    $pkgnames.delete(pkg.fullname)
  end
end

def fix_db_phase2()
  $pkgnames.each do |pkgname|
    puts "Checking #{pkgname}"

    # check and fix dependencies
    fix_dependencies(pkgname)
  end

  # reconstruct all the +REQUIRED_BY files
  puts "Regenerating +REQUIRED_BY files"

  tsort = TSort.new
  indep_pkgnames = []

  $pkgnames.each do |pkgname|
    req_file = $pkgdb.pkg_required_by(pkgname)

    if $req_hash.key?(pkgname)
      req_by = $req_hash[pkgname].keys

      req_by.each { |req|
	tsort.add(req, pkgname)
      }

      File.open(req_file, "w") do |f|
	f.puts(*req_by.sort)
      end
    else
      indep_pkgnames << pkgname

      File.unlink(req_file) if File.exist?(req_file)
    end
  end

  # unlink cyclic dependencies
  puts "Checking for cyclic dependencies"

  fix_cycles(tsort)
end

def fix_origin(pkg)
  pkgname = pkg.fullname
  origin = pkg.origin

  if origin
    if $portsdb.exist?(origin, true)
      return origin
    end

    puts "Stale origin: '#{origin}': perhaps moved or obsoleted."
  else
    puts "Missing origin."
  end

  if config_held?(pkg) && !$force
    puts "-> Ignored. (the package is held; specify -f to force)"
    return false
  end

  if prompt_yesno("Skip this for now?", true)
    puts "To skip it without asking in future, please list it in HOLD_PKGS."
    return false
  end

  if origin
    if prompt_yesno("Check out the port's history via CVSweb?", false)
      Dir.chdir($ports_dir) {
	system(PkgDB::CMD[:portcvsweb], File.join(origin, "Makefile"))
      }
    end

    origin = nil
  end

  print "Guessing... "
  STDOUT.flush

  begin
    guess = $portsdb.glob(pkg.name).max { |a, b|
      matchlen(pkgname, a.pkgname.to_s) <=> matchlen(pkgname, b.pkgname.to_s)
    }

    if guess
      guess = guess.origin

      puts ''

      if confirm_port(guess)
	origin = guess
      end
    else
      puts "no idea."
    end
  rescue => e
    puts e.message
  end

  origin ||= input_port('New origin?')

  case origin
  when :abort
    puts "Abort."
    exit
  when :skip
    puts "Skipped."
    return false
  when :delete
    if pkg.required?
      puts "-> Hint:  #{pkgname} is required by the following package(s):"

      pkg.required_by.each do |req|
	puts "\t#{req}"
      end
    else
      puts "-> Hint: #{pkgname} is not required by any other package"
    end

    puts "-> Hint: checking for overwritten files..."

    possible_successors = []

    pkg.files.each do |path|
      owners = $pkgdb.which_m(path) or
	raise PkgDB::DBError, "#{path} is not properly recorded as installed - please run pkgdb -fu"

      i = owners.index(pkgname) or
	raise PkgDB::DBError, "#{path} is not properly recorded as installed by #{pkgname} - please run pkgdb -fu"

      if i != owners.size - 1
	overwriters = owners[(i + 1)..-1]

	puts "\t#{path}: overwritten by: #{overwriters.join(' ')}"	#

	possible_successors |= overwriters
      end
    end

    if possible_successors.empty?
      puts " -> No files installed by #{pkgname} have been overwritten by other packages."
    else
      puts " -> The package may have been succeeded by some of the following package(s):"
      possible_successors.each do |s|
	puts "\t#{s}"
      end

      if prompt_yesno("Unregister #{pkgname} keeping the installed files intact?", false)
	puts "--> Unregistering #{pkgname}"

	if system('/bin/rm', '-rf', pkg.pkgdir)
	  $pkgdb.update_db

	  puts "--> Done."
	  return nil
	else
	  puts "--> Failed."
	  return false
	end
      end
    end

    if prompt_yesno("Deinstall #{pkgname} ?", false)
      # pkg_deinstall will update the pkgdb
      $pkgdb.close_db

      if system(PkgDB::CMD[:pkg_deinstall], pkgname)
	puts "--> Done."
	return nil
      else
	puts "--> Failed."
	return false
      end
    end
  else
    contents_file = $pkgdb.pkg_contents(pkgname)

    begin
      subst_file(/^@comment\s+ORIGIN:.*\n/, '', contents_file)

      File.open(contents_file, "a") do |f|
	f.puts "@comment ORIGIN:" + origin
	f.close
      end

      $pkgdb.set_origin(pkgname, origin)

      puts "Fixed. (-> #{origin})"
    rescue => e
      puts "Failed to rewrite #{contents_file}: " + e.message
    end
  end

  origin
end

def fix_dependencies(pkgname)
  deps = $pkgdb.pkgdep(pkgname) or return

  deps.each do |dep|
    catch(:next_dep) {
      unless $pkgnames.include?(dep)
	puts "Stale dependency: #{pkgname} -> #{dep}:"

	if config_held?(pkgname) && !$force
	  puts "-> Ignored. (the package is held; specify -f to force)"
	  throw :next_dep
	end

	fix = $fix_hash[dep]
	fix_score = nil

	if fix.nil?
	  begin
	    deppkg = PkgInfo.new(dep)
	    deppkgname = deppkg.fullname

	    prefixes = PortsDB::LANGUAGE_SPECIFIC_CATEGORIES.values
	    prefix_re = prefixes.join('|')
	    pkgname_re = /^(#{prefix_re})?(.+?)((?:\+[^+\-]+)+)?(-[^\-]+)$/

	    deppkg_prefix, deppkg_base, deppkg_suffix, deppkg_version =
	      pkgname_re.match(deppkgname)[1..-1]

	    calc_score = proc { |name|
	      score = 0

	      name_prefix, name_base, name_suffix, name_version =
		pkgname_re.match(name)[1..-1]

	      if name_prefix != deppkg_prefix
		score -= 1
	      elsif deppkg_prefix
		score += 5
	      end

	      if name_suffix != deppkg_suffix
		score -= 1

		if name_suffix && deppkg_suffix
		  n = [deppkg_suffix.size, name_suffix.size].max

		  score += 20 * matchlen(name_suffix, deppkg_suffix) / n
		end
	      elsif deppkg_suffix
		score += 20
	      end

	      if name_base == deppkg_base
		score += 50

		score += 5 * matchlen(name_version, deppkg_version)
	      else
		n = matchlen(name_base, deppkg_base)

		if n >= 3
		  score += 5 * n
		else
		  score = 0
		end
	      end

	      if score < 0
		score = 0
	      end

	      score
	    }

	    full = calc_score.call(deppkgname)

	    score, name = $pkgnames.map { |name|
	      score = calc_score.call(name) * 100 / full

	      [score, name]
	    }.max

	    if score.nonzero?
	      fix = name
	      fix_score = score
	    end
	  rescue => e
	    puts e.message
	  end
	end

	catch(:fix_pkgdep) {
	  if fix
	    skip = (fix == :skip)

	    if $all_list.include?(dep)
	      if skip
		puts "Skipped."
		throw :next_dep
	      end

	      throw :fix_pkgdep
	    end

	    default_ans = true

	    case fix
	    when :skip
	      prompt = "Skip this?"
	    when :delete
	      prompt = "Delete this?"
	    else
	      if fix_score
		prompt = "#{fix} (score:#{fix_score}%) ?"
	        default_ans = fix_score >= 80
	      else
		prompt = "#{fix} ?"
	      end
	    end

	    ans = prompt_yesnoall(prompt, default_ans)

	    if ans == :all
	      $all_list << dep
	    end

	    if ans
	      if skip
		throw :next_dep
	      end

	      throw :fix_pkgdep
	    end
	  end

	  fix = input_pkg('New dependency?', true)

	  case fix
	  when :abort
	    puts "Abort."
	    exit
	  when :skip
	    puts "Skipped."
	    $fix_hash[dep] = :skip
	    throw :next_dep
	  when :skip_all
	    $fix_hash[dep] = :skip
	    $all_list << dep
	    throw :next_dep
	  when :delete_all
	    $all_list << dep
	    fix = :delete
	  end
	}

	contents_file = $pkgdb.pkg_contents(pkgname)

	begin
	  File.open(contents_file, "r+") do |f|
	    lines = []
	    pkgdeps = {}

	    f.each do |line|
	      if /^@pkgdep\s+(\S+)/ =~ line
		pkgdep = $1

		next if pkgdeps.key?(pkgdep)	# remove duplicates

		pkgdeps[pkgdep] = true

		if $1 == dep
		  if fix == :delete
		    lines << "@comment DELETED:pkgdep #{pkgdep}\n"
		  else
		    lines << "@pkgdep #{fix}\n"

		    pkgdeps[fix] = true
		  end

		  next
		end
	      end

	      lines << line
	    end

	    f.rewind
	    f.print(*lines)
	    f.truncate f.pos
	    f.close

	    case fix
	    when :delete
	      puts "Deleted."
	    else
	      puts "Fixed. (-> #{fix})"
	    end

	    $fix_hash[dep] = fix
	    dep = fix
	  end
	rescue => e
	  puts "Failed to rewrite #{contents_file}: " + e.message
	end
      end

      if fix != :delete
	($req_hash[dep] ||= {})[pkgname] = true
      end
    }
  end
end

def fix_cycles(tsort)
  skip_all = false

  tsort.tsort! do |cycle|
    puts "Cyclic dependencies: #{cycle.join(' -> ')} -> (#{cycle[0]})"

    i = nil

    loop do
      ans = skip_all || \
	cycle.size == 1 ? cycle[0] : input_pkg('Unlink which dependency?', false, cycle)

      case ans
      when :abort
	puts "Abort."
	exit
      else
	i = cycle.index(ans)

	if cycle[i + 1].nil?
	  a, b = cycle.last, cycle.first
	else
	  a, b = cycle.indices(i, i + 1)
	end

	if prompt_yesno("Unlink #{a} -> #{b} ?", true)
	  file = $pkgdb.pkg_contents(a)

	  File.open(file, "r+") do |f|
	    lines = []
	    pkgdeps = { b => true }

	    f.each do |line|
	      if /^@pkgdep\s+(\S+)/ =~ line
		pkgdep = $1

		next if pkgdeps.key?(pkgdep)	# remove duplicates

		pkgdeps[pkgdep] = true
	      end

	      lines << line
	    end

	    f.rewind
	    f.print(*lines)
	    f.truncate f.pos
	    f.close
	  end

	  file = $pkgdb.pkg_required_by(b)

	  subst_file(/^#{Regexp.quote(a)}\n/, '', file) #

	  if File.zero?(file)
	    File.unlink(file)
	  end

	  puts 'Done.'
	  break
	end
      end
    end

    i
  end
end

def fix_duplicates(org_hash)
  all_deleted = []

  $pkgdb.close_db

  org_hash.each do |origin, pkgs|
    next if pkgs.size < 2

    pkgs.sort!
    n = pkgs.size

    puts "Duplicated origin: #{origin} - " + pkgs.collect { |pkg| pkg.fullname }.join(' ')

    prompt_yesno("Unregister any of them?", false) or next

    deleted = []

    pkgs.each do |pkg|
      pkgname = pkg.fullname

      if n == 1
	# automatically keep one package record at least
	puts "  -> #{pkgname} is kept."
	break
      end

      prompt_yesno("  Unregister #{pkgname} keeping the installed files intact?", false) or next

      deleted << pkg

      n -= 1
    end

    unless deleted.empty?
      biggest = (pkgs - deleted)[-1]

      deleted.each do |pkg|
	oldpkgdir = pkg.pkgdir
	oldpkgname = pkg.fullname

	newpkgdir = biggest.pkgdir
	newpkgname = biggest.fullname

	contents = $pkgdb.pkg_contents(oldpkgname)
	backup = $pkgdb.pkg_contents(newpkgname) + '.' + oldpkgname

	puts "  --> Saving the #{oldpkgname}'s +CONTENTS file as #{backup}"
	system('/bin/cp', '-pf', contents, backup) or next

	puts "  --> Unregistering #{oldpkgname}"
	system('/bin/rm', '-rf', oldpkgdir) or next

	puts "  --> Done."

	all_deleted << pkg
      end
    end
  end

  $pkgdb.update_db unless all_deleted.empty?

  all_deleted
end

def input_pkg(message = 'Which package?', fullspec = false, pkgnames = $pkgnames)
  flags = OPTIONS_HISTORY |
    (fullspec ? OPTIONS_SKIP | OPTIONS_DELETE | OPTIONS_ALL : OPTIONS_NONE)

  choose_from_options(message, pkgnames, flags)
end

def input_port(message = 'Which port?')
  loop do
    input = input_file(message + ' (? to help): ', $ports_dir, true)

    if input.nil?
      print "\n"
      return :delete
    end

    input.strip!

    case input
    when '.'
      return :abort
    when '?'
      print <<-EOF
[Enter] to skip, [Ctrl]+[D] to unregister or deinstall,
[.][Enter] to abort, [Tab] to complete
      EOF
      next
    when ''
      if prompt_yesno("Skip this?", true)
	return :skip
      end

      next
    else
      input = $portsdb.strip(input)

      confirm_port(input) and return input
    end
  end

  # not reached
end

def confirm_port(origin)
  if origin
    pkgname = $portsdb.exist?(origin)

    if !pkgname
      return prompt_yesno("#{origin}: Not found.  Force it?", false)
    end

    return prompt_yesno("#{origin} (#{pkgname}): Change the origin to this?", true)
  end

  puts "Not in due form <category/portname>: #{origin}"

  false
end

def signal_handler(sig)
  puts "\nInterrupted."

  stty_sane

  exit
end

if $0 == __FILE__
  for sig in [2, 3, 15]
    trap(sig) do
      signal_handler(sig)
    end
  end

  exit main(ARGV)
end
