#!/usr/bin/env oo-ruby

require 'optparse'
require 'ostruct'

PATH = "#{ENV['OPENSHIFT_BROKER_DIR'] || '/var/www/openshift/broker'}/config/environment"

class Command
  def import_node(options)
    env!(options)

    carts = []

    if options.node
      carts = OpenShift::ApplicationContainerProxy.instance(options.node).get_available_cartridges
    else
      node_platforms = Rails.configuration.openshift[:node_platforms]

      node_platforms.each do |platform|
        node_carts = OpenShift::ApplicationContainerProxy.find_one(nil, platform).get_available_cartridges
        node_carts.each do |cart|
          carts << cart unless carts.any? {|existing_cart| existing_cart.name == cart.name }
        end
      end
    end

    types = CartridgeType.update_from(carts, nil, options.force)
    update_types(options, types)
  rescue OpenShift::NodeException
    if options.node
      warn "The server #{options.node} did not respond to the request to get installed cartridges."
    else
      warn "Unable to communicate with a node to get installed cartridges: #{$!}"
    end
    1
  end

  def clean(options)
    env!(options)
    puts "Deleting all unused cartridges from the broker ..."
    CartridgeType.inactive.each do |t|
      found = Application.where('component_instances.cartridge_id' => t._id).count
      if found == 0
        unless options.dry_run
          t.delete
        end
        puts "#{t._id} # %-35s" % [t.name, found]
      else
        warn "# #{t._id} %-35s %s" % [t.name, found]
      end
    end
    0
  rescue => e
    exception e, "Failed to delete some cartridges"
    1
  end

  def list(options)
    env!(options)
    carts = CartridgeType.all
    carts = carts.active if options.active
    carts = carts.in(name: options.names) if options.names
    carts = carts.in(_id: options.ids) if options.ids
    return 0 if carts.empty?

    if options.raw
      carts.each do |type|
        puts JSON.pretty_generate(type.cartridge.to_descriptor)
      end
      return 0
    end

    output = carts.map do |type|
      line = [
        type.priority? ? '*' : ' ',
        type.name,
        case
        when type.is_web_proxy? then "web_proxy"
        when type.is_web_framework? then "web"
        when type.is_plugin? then "plugin"
        when type.is_external? then "external"
        else "service"
        end,
        type.display_name,
        type.created_at.strftime("%Y/%m/%d %H:%M:%S %Z"),
        type._id.to_s,
        type.obsolete? ? 'obsolete' : '',
        type.manifest_url,
      ]
      line
    end
    output.sort_by!{ |line| [line[2], line[5].nil? ? 0 : 1, line[1]] }
    if options.quiet
      output.map!{ |line| [line[5]] }
    elsif options.all
    else
      output.each{ |line| line.delete_at(5) }
    end

    table(output)
    0
  rescue => e
    warn "Failed to list cartridges"
    warn e.message
    warn e.backtrace
    1
  end

  def delete(options)
    ids = from_options_or_stdin(options, [:names, :ids], '--name NAMES')
    env!(options)
    carts = CartridgeType.or({:name.in => ids}, {:_id.in => ids})
    if carts.empty?
      warn "No cartridges match #{ids.map{|s| "'#{s}'"}.join(', ')}."
      return 2
    end
    names = carts.map{ |c| "'#{c.name}'" }.join(', ')
    unless options.dry_run
      carts.delete
    end
    puts "#{names} were deleted."
    0
  rescue ArgumentError
    warn $!
    1
  rescue => e
    exception e, "Failed to delete cartridges"
    1
  end

  def deactivate(options)
    ids = from_options_or_stdin(options, [:names, :ids], '--ids IDS')
    env!(options)
    CartridgeType.or({:name.in => ids}, {:_id.in => ids}).each do |cart|
      if cart.priority
        unless options.dry_run
          cart.priority = nil
          cart.save!
        end
        puts "#{cart.name} was deactivated."
      else
        puts "#{cart.name} was not active."
      end
    end
    0
  rescue ArgumentError
    warn $!
    1
  rescue => e
    exception e, "Failed to deactivate cartridge"
    1
  end

  def activate(options)
    ids = from_options_or_stdin(options, [:names, :ids], '--ids IDS')
    env!(options)
    code = 0
    CartridgeType.or({:name.in => ids}, {:_id.in => ids}).each do |cart|
      if cart.obsolete? && !options.obsolete
        warn "#{cart.name} is obsolete. Please pass --obsolete to activate."
      elsif options.dry_run
        puts "#{cart._id} # now the active cartridge for #{cart.name}"
      elsif cart.activate
        puts "#{cart._id} # now the active cartridge for #{cart.name}"
      else
        warn "Unable to activate #{options.id}"
        type.errors.full_messages.each do |m|
          warn "  #{m}"
        end
        code = 1
      end
    end
    code
  rescue ArgumentError
    warn $!
    1
  rescue => e
    exception e, "Failed to activate cartridge"
    1
  end

  def categorize(options)
    ids = from_options_or_stdin(options, [:names, :ids], '--ids IDS')
    add = ARGV.select{ |i| i[0] == '+' }.map{ |i| i[1..-1] }.uniq
    remove = ARGV.select{ |i| i[0] == '=' }.map{ |i| i[1..-1] }.uniq
    r = remove
    remove = remove - add
    add = add - r
    raise ArgumentError, "Must specify categories to add with '+' or remove with 'x' like '+foo =bar' " if add.empty? && remove.empty?
    env!(options)
    code = 0
    CartridgeType.or({:name.in => ids}, {:_id.in => ids}).each do |cart|
      cart.categories -= remove
      cart.categories += add
      cart.categories.uniq!
      if options.dry_run
        puts "#{cart._id} # #{cart.categories.sort.join(', ')}"
      elsif cart.save
        puts "#{cart._id} # #{cart.categories.sort.join(', ')}"
      else
        warn "Unable to save #{cart._id}"
        type.errors.full_messages.each do |m|
          warn "  #{m}"
        end
        code = 1
      end
    end
    code
  rescue ArgumentError
    warn $!
    1
  rescue => e
    exception e, "Failed to activate cartridge"
    1
  end

  def import(options)
    raise ArgumentError, "Use --url URL or specify a list of files via STDIN" if (options.url.nil? || options.url.empty?) && (files = ARGF.readlines).empty?
    env!(options)

    if options.url
      text = CartridgeCache.download_from_url(options.url)
      versions = OpenShift::Runtime::Manifest.manifests_from_yaml(text)
      types = CartridgeType.update_from(versions, options.url)
    else
      types = []
      files.each do |s|
        s.chomp!
        warn "Importing #{s} ..."
        text = IO.read(File.expand_path(s))
        versions = OpenShift::Runtime::Manifest.manifests_from_yaml(text)
        types.concat(CartridgeType.update_from(versions, nil, options.force))
      end
    end

    if not options.force
      source_change, types = types.partition{ |t| t.manifest_url_changed? && t.persisted? }
      if not source_change.empty?
        warn "Some cartridges had their manifest URLs changed:"
        source_change.each do |c|
          warn "  #{c.name}: #{c.changes['manifest_url'][0]} -> #{c.changes['manifest_url'][1]}"
        end
        warn "You must pass --force to update manifest URLs for imported cartridges"
      end
    end

    update_types(options, types, "from #{options.url}")
  rescue KeyError
    warn text
    raise
  rescue => e
    exception e, "Failed to import cartridge"
    1
  end

  def migrate(options)
    env!(options)

    cartridges = 0
    completed = 0
    applications = 0
    incomplete = 0
    CartridgeType.inactive.each do |t|
      if active = CartridgeType.active.where(name: t.name).first
        cartridges += 1
        if options.dry_run
          updated = Application.where(:pending_op_groups.with_size => 0, 'component_instances.cartridge_id' => t._id).count
        else
          result  = Application.where(:pending_op_groups.with_size => 0, 'component_instances.cartridge_id' => t._id).update_all('$set' => {'component_instances.$.cartridge_id' => active._id})
          updated = result['n']
        end
        applications += updated
        if (remaining = Application.where('component_instances.cartridge_id' => t._id).count) > 0
          warn "#{t._id} ! -> #{active._id} INCOMPLETE %7s / %3s left %s" % [updated, remaining, active.name]
          incomplete += remaining
        else
          puts "#{t._id} # -> #{active._id} DONE       %7s   %3s      %s" % [updated, nil, active.name]
          completed += 1
        end
      else
        if (count = Application.where('component_instances.cartridge_id' => t._id).count) > 0
          warn "No active upgrade for #{t.name} (#{t._id}) - #{count} applications found"
        end
      end
    end
    if completed > 0 || incomplete > 0
      warn "Migrated #{completed}/#{cartridges} cartridges"
      warn "Updated #{applications} applications"
      warn "Skipped #{incomplete} applications with pending jobs"
    else
      warn "No cartridges can be migrated"
    end
    incomplete == 0 ? 0 : 2
  end

  def diff(options)
    raise ArgumentError, "diff requires exactly two IDs or names" if (options.names || []).length != 2 && (options.ids || []).uniq.length != 2
    env!(options)
    carts =
      if options.names
        if options.names.uniq.length == 1
          [CartridgeType.active.where(name: options.names.first).first, CartridgeType.inactive.where(name: options.names.last).order_by(created_at: 1).first]
        else
          [CartridgeType.active.where(name: options.names.first).first, CartridgeType.active.where(name: options.names.last).first]
        end
      else
        CartridgeType.find(*options.ids)
      end.compact
    if carts.uniq.length == 1
      warn "No differences between the specified carts #{carts.map(&:_id)}"
      return 0
    elsif carts.empty?
      warn "No cartridges match"
      return 1
    end
    warn "#{cartridge_label(carts.first)}\n#{cartridge_label(carts.last)}\n---"

    objs = carts.map(&:to_descriptor).each{ |o| o.delete('Id') }
    if diffs = different?(*objs)
      puts JSON.pretty_generate(diffs)
      1
    else
      warn "Manifests are identical"
      0
    end
  end

  protected
    def env!(options)
      require options.broker || PATH
    end

    def from_options_or_stdin(options, option, option_name=nil)
      option = Array(option).find{ |s| options.send(s) }
      items =
        if option && value = options.send(option)
          value
        else
          ARGF.map do |line|
            text = line.gsub(/([^\\])#.*\Z/, "$1").strip
            text if text.length > 0
          end.compact
        end
      raise ArgumentError, "You must pass one or more filenames, pipe to stdin, or specify the option #{option_name}." if items.empty?
      items
    end

    def table(output)
      widths = Array.new(output.first.length, 0)
      output.each{ |line| widths.each_with_index{ |w, i| widths[i] = [w, (line[i] || "").length].max } }
      fmt = widths.map{ |w| "%-#{w}s" }.join(" ")
      output.each{ |line| puts (fmt % line).rstrip }
    end

    def cartridge_label(cart)
      "#{cart.name} #{cart._id}#{cart.priority? ? ' *' : ''}"
    end

    def update_types(options, types, source=nil)
      if types.empty?
        warn "No changes#{ " #{source}" if source }"
        return 0
      end
      warn "Updating #{types.length} cartridges#{ " #{source}" if source } ..."
      if types.inject(0){ |f, type| f + save_cart(options, type) } > 0
        1
      else
        0
      end
    end

    def save_cart(options, type)
      op = type.persisted? || type.has_predecessor? ? "update" : "add"
      activate = options.activate && (!type.obsolete? || options.obsolete)
      if options.dry_run
        puts "#{type._id} # #{op.capitalize[0]} #{type.name}#{ " (obsolete)" if type.obsolete? }#{ " (active)" if activate }"
      elsif type.send(activate ? :activate : :save)
        puts "#{type._id} # #{op.capitalize[0]} #{type.name}#{ " (obsolete)" if type.obsolete? }#{ " (active)" if activate }"
      else
        warn "Failed to #{op} #{type.name}"
        type.errors.full_messages.each do |m|
          warn "  #{m}"
        end
      end
      0
    rescue => e
      exception e, "Failed to #{op} #{type.name}"
      1
    end

    def warn(*args)
      $stderr.puts(*args)
    end

    def exception(e, *args)
      $stderr.puts(*args)
      $stderr.puts e.message
      $stderr.puts "  #{e.backtrace.join("  \n")}"
    end

    #
    # Based on https://gist.github.com/agius/2631752
    #
    def different?(a, b, bi_directional=true)
      return [a.class.name, nil] if !a.nil? && b.nil?
      return [nil, b.class.name] if !b.nil? && a.nil?

      differences = {}
      a.each do |k, v|
        if !v.nil? && b[k].nil?
          differences[k] = [v, nil]
          next
        elsif !b[k].nil? && v.nil?
          differences[k] = [nil, b[k]]
          next
        end
        if v.is_a?(Hash)
          unless b[k].is_a?(Hash)
            differences[k] = "Different types"
            next
          end
          diff = different?(a[k], b[k])
          differences[k] = diff if !diff.nil? && diff.count > 0
        elsif v.is_a?(Array)
          unless b[k].is_a?(Array)
            differences[k] = "Different types"
            next
          end
          c = 0
          diff = v.map do |n|
            if n.is_a?(Hash)
              diffs = different?(n, b[k][c])
              c += 1
              ["Differences: ", diffs] unless diffs.nil?
            else
              c += 1
              [n , b[c]] unless b[c] == n
            end
          end.compact
          differences[k] = diff if diff.count > 0
        else
          differences[k] = [v, b[k]] unless v == b[k]
        end
      end
      return differences if !differences.nil? && differences.count > 0
    end
end

methods = (Command.instance_methods(false) & Command.new.public_methods).map{ |s| s.to_s.gsub('_', '-')}.sort
options = OpenStruct.new
p = OptionParser.new do |opts|
  opts.banner = "
== Synopsis

#{File.basename $0}: Manage cartridges

Commands:
  activate    - Make a specific cartridge active.
                Requires id or name, or input from STDIN.

  deactivate  - Disable a cartridge from being visible to users
                Requires id or name, or input from STDIN.

  categorize  - Add/remove categories on a cartridge.
                Requires id or name, or input from STDIN. Pass categories
                to set with '+<category>' or remove with '=<category>'

  clean       - Delete all cartridges in the broker

  delete      - Remove one or more named cartridges

  diff        - Compare two cartridges
                Requires two ids or names

  import      - Import a manifest as one or more cartridges
                Requires --url

  import-node - Import the latest cartridges from a randomly selected node.

  list        - List all cartridges

  migrate     - Run a migration of old cartridge versions to the latest
                active version.  Does not migrate cartridges without an
                active version.

For delete, activate, and deactivate, you may pass a file containing ids
or names.

== Usage: oo-admin-ctl-cartridge -c (#{methods.join('|')})"

  opts.separator ''
  opts.on('-c', '--command COMMAND',
          methods.map(&:to_s),
          [],
          "A command to execute") do |command|
    options.command = command
  end

  opts.on('--broker PATH', "The path to the broker",
          " (default #{PATH})") do |path|
    options.broker = path
  end

  opts.on('--raw', "Dump all cartridge information as JSON") do
    options.raw = true
  end

  opts.on('--force', "Replace with new version even if unchanged") do
    options.force = true
  end

  opts.on('--obsolete', "Force activation of obsolete cartridges") do
    options.obsolete = true
  end

  opts.on('-a', '--active', "Show only active cartridges") do |url|
    options.active = true
  end

  opts.on('--activate', "Mark imported or updated cartridges as active.") do
    options.activate = true
  end

  opts.on('--dry-run', "Show the results of the update without changing anything.") do
    options.dry_run = true
  end

  opts.on('-n', '--name NAMES', "Comma-delimited cartridge names.") do |names|
    options.names = names.split(/[\, ]/)
  end

  opts.on('-q', "Display only ids") do
    options.quiet = true
  end

  opts.on('--ids IDS', "ID for a cartridge version to activate or deactivate (comma-delimited).") do |ids|
    options.ids = ids.split(/[\, ]/)
  end

  opts.on('--node NODE', "Identifier for a node (server-identity) to import from.") do |node|
    options.node = node
  end

  opts.on('-u URL', '--url URL', "URL of a cartrige manifest to import.") do |url|
    options.url = url
  end

  opts.on_tail("-h", "--help", "Show this message") do
    puts opts
    exit 0
  end
end
p.parse!(ARGV)

if options.command.nil?
  puts p
  exit 0
end
exit Command.new.send(options.command.downcase.gsub(/[\-]/,'_'), options)
