#!/usr/bin/env 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/pkgdb,v 1.4 2001/10/02 22:50:51 knu Exp $
RCS_REVISION = RCS_ID.split[2]
MYNAME = File.basename($0)

require "optparse"
require "pkgtools"

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

$force = false
$fix_db = false
$update_db = false

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

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

#{usage}
  EOF

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

    opts.def_option("-f", "--force", "Force to update database regardless of#{NEXTLINE}timestamps") {
      |$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|
      puts($pkgdb.origin(pkgname) || '(unknown)')
    }

    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

      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]:
    PORTSDIR                 ports directory [/usr/ports]
    PKG_DBDIR                packages DB directory [/var/db/pkg]
    PORTS_INDEX              ports index file [$PORTSDIR/INDEX]
    PORTS_DBDIR              ports db directory [$PORTSDIR]'

    if argv.empty?
      print opts
      return 0
    end

    begin
      opts.order!(argv)

      if $update_db
	$pkgdb.update_db($force)
      end

      if $fix_db
	fix_db()
      end

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

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

	printf "%s: %s\n", path, $pkgdb.which(path) || '?'
      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
  require "readline" unless defined?(Readline)

  stty_sane

  $pkgnames = $pkgdb.installed_pkgs

  $req_hash = {}	# a hash of pkgname => [pkg1, pkg2,, ...] pairs ("required by what?")
  $fix_hash = {}	# a hash of pkgname => ans pairs
  $all_list = []	# an array of pkgnames, with which a user answered "all"

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

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

    pkg = PkgInfo.new(pkgname)
      
    if origin = fix_origin(pkg)
      if org_hash.key?(origin)
	org_hash[origin] << pkg
      else
	org_hash[origin] = [pkg]
      end
    end
  end

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

    # check and fix dependencies
    fix_dependencies(pkgname)
  end

  # reconstruct all the +REQUIRED_BY files
  tsort = TSort.new
  indep_pkgnames = []

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

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

      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
  fix_cycles(tsort)

  # fix origin duplicates
  if fix_duplicates(org_hash)
    puts "Redo."
    fix_db()
  end
end

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

  if origin
    portdir = File.join($ports_dir, origin)

    if File.directory? portdir
      return origin
    end

    puts "Stale origin: '#{origin}'"
    origin = nil
  else
    puts "Missing origin."
  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
  else
    contents_file = $pkgdb.pkg_contents(pkgname)
    
    begin      
      File.open(contents_file, "r+") do |f|
	lines = f.reject { |line| /^@comment +ORIGIN:/ =~ line }

	f.rewind
	f.print lines
	f.puts "@comment ORIGIN:" + origin
	len = f.pos
	f.close
	File.truncate contents_file, len
      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 next

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

	fix = $fix_hash[dep]

	begin
	  deppkg = PkgInfo.new(dep)
	  deppkgname = deppkg.fullname
	  deppkg_re = /^#{Regexp.quote(deppkg.name)}-[^-]+$/

	  if fix.nil?
	    fix = $pkgnames.select { |name|
	      deppkg_re =~ name
	    }.max { |a, b|
	      matchlen(deppkgname, a) <=> matchlen(deppkgname, b)
	    }
	  end
	rescue => e
	  puts e.message
	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

	    case fix
	    when :skip
	      prompt = "Skip this?"
	    when :delete
	      prompt = "Delete this?"
	    else
	      prompt = fix + " ?"
	    end

	    ans = prompt_yesnoall(prompt, true)

	    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 = []

	    f.each { |line|
	      if /^(@pkgdep +)#{Regexp.quote(dep)}$/ =~ line
		lines << $1 + fix + "\n" if fix != :delete
	      else
		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
	if $req_hash.include?(dep)
	  $req_hash[dep] << pkgname
	else
	  $req_hash[dep] = [pkgname]
	end
      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 = f.select { |line|
	      /^(@pkgdep +)#{Regexp.quote(b)}$/ !~ line
	    }

	    f.rewind
	    f.print lines
	    f.truncate f.pos
	    f.close
	  end

	  file = $pkgdb.pkg_required_by(b)

	  File.open(file, "r+") do |f|
	    lines = f.select { |line|
	      line.chomp != a
	    }

	    if lines.empty?
	      f.close
	      File.unlink(file)
	    else
	      f.rewind
	      f.print lines
	      f.truncate f.pos
	      f.close
	    end
	  end		

	  puts 'Done.'
	  break
	end
      end
    end

    i
  end
end
    
def fix_duplicates(org_hash)
  all_removed = []

  $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("Remove any of them?", false) or next

    removed = []

    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("  Remove the package record of #{pkgname} ?", false) or next

      removed << pkg
      
      n -= 1
    end

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

      removed.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 "  --> Removing the #{oldpkgname}'s record"
	system('/bin/rm', '-rf', oldpkgdir) or next

	puts "  --> Done"

	all_removed << pkg
      end
    end
  end

  if all_removed.empty?
    nil
  else
    $pkgdb.update_db

    all_removed
  end
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"
      next
    end

    input.strip!

    case input
    when '.'
      return :abort
    when '?'
      print <<-EOF
[Enter] to skip, [.][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.exists?(origin)

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

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

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

  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
