#!/usr/bin/env ruby

#
# cookbook_munger.rb -- keep cookbook metadata complete, consistent and correct.
#
# This script reads the actual content of a cookbook -- actually interpreting
# the metadata.rb and attribute files, along with recipes/resources/etc files'
# headers -- and re-generates the metadata.rb and README files.
#
# It also has hooks to do a limited amount of validation and linting.
#

require 'erubis'
require 'chef/mash'
require 'chef/mixin/from_file'

require 'configliere'
require 'gorillib/metaprogramming/class_attribute'
require 'gorillib/hash/reverse_merge'
require 'gorillib/object/blank'
require 'gorillib/hash/compact'
require 'gorillib/string/inflections'
require 'gorillib/string/human'
require 'gorillib/logger/log'
require 'set'

$:.unshift File.expand_path('..', File.dirname(__FILE__))
require 'cluster_chef/dsl_object'

# silence the chef log
class Chef ; class Log ; def self.info(*args) ; end ; def self.debug(*args) ; end ; end ; end

Settings.define :maintainer,       :default => 'default mantainer name', :default => "Philip (flip) Kromer - Infochimps, Inc"
Settings.define :maintainer_email, :default => 'default email to add to cookbook headers', :default => "coders@infochimps.com"
Settings.define :license,          :default => 'default license to apply to cookbooks', :default => "Apache 2.0"
#
Settings.define :cookbook_paths,   :description => 'list of paths holding cookbooks', :type => Array, :default => ["./{site-cookbooks,meta-cookbooks}"]
#
Settings.use(:commandline)
Settings.resolve!

String.class_eval do
  def commentize
    self.gsub(/\n/, "\n# ").gsub(/\n# \n/, "\n#\n")
  end
end

module CookbookMunger
  TEMPLATE_ROOT  = File.expand_path('cookbook_munger', File.dirname(__FILE__))

  # ===========================================================================
  #
  # DummyAttribute -- holds metadata about a single cookbook attribute.
  #
  # named like a path: node[:pig][:home_dir] is 'pig/home_dir'
  #
  class DummyAttribute
    attr_accessor :name
    attr_accessor :display_name
    attr_accessor :description
    attr_accessor :choice
    attr_accessor :calculated
    attr_accessor :type
    attr_accessor :required
    attr_accessor :recipes
    attr_accessor :default

    def initialize(name, hsh={})
      self.name = name
      merge!(hsh)
      @display_name ||= ''
    end

    def merge!(hsh)
      hsh.each do |key, val|
        self.send("#{key}=", val) unless val.blank?
      end
    end

    def inspect
      "attr[#{name}:#{default.inspect}]"
    end

    def bracketed_name
      name.split("/").map{|s| "[:#{s}]" }.join
    end

    def keys
      [:display_name, :description, :choice, :calculated, :type, :required, :recipes, :default]
    end

    def to_hash
      hsh = {}
      self.description = display_name if description.blank?
      keys.each do |key|
        hsh[key] = self.send(key) if instance_variable_defined?("@#{key}")
      end
      case hsh[:default]
      when Symbol, Numeric, TrueClass, NilClass, FalseClass then hsh[:default] = hsh[:default].to_s
      when Hash            then hsh[:type] ||= 'hash'
      when Array           then hsh[:type] ||= 'array'
      end
      hsh
    end

    def pretty_str
      str = [ %Q{attribute "#{name}"} ]
      to_hash.each do |key, val|
        str << ("  :%-21s => %s" % [ key, val.inspect ])
      end
      str.flatten.join(",\n")
    end

  end

  # ===========================================================================
  #
  # DummyAttributeCollection -- the cascading buckets to hold attributes
  #
  # This auto-vivifies: just saying `foo[:bar][:baz][:bing]` results in
  # foo becoming
  #     `{ :bar => { :baz => { :bing => {} } } }`
  #
  class DummyAttributeCollection < Mash
    attr_accessor :path

    def initialize(path='')
      self.path = path
      super(){|hsh,key| hsh[key] = DummyAttributeCollection.new(sub_path(key)) }
    end

    def setter(key=nil)
      # key ? (self[key] = DummyAttributeCollection.new(sub_path(key))) : self
      self
    end

    def sub_path(key)
      path.blank? ? key.to_s : "#{path}/#{key}"
    end

    def []=(key, val)
      unless val.is_a?(DummyAttributeCollection) || val.is_a?(DummyAttribute)
        val = DummyAttribute.new(sub_path(key), :default =>val)
      end
      super(key, val)
    end

    def attrs
      [ leafs.values, branches.map{|key,val| val.attrs } ].flatten
    end

    def leafs
      select{|key,val| not val.is_a?(DummyAttributeCollection) }
    end
    def branches
      select{|key,val|     val.is_a?(DummyAttributeCollection) }
    end

    def pretty_str
      str = []
      attrs.each{|attrib| str << attrib.pretty_str }
      str.join("\n\n")
    end

  end

  # ===========================================================================
  #
  # CookbookComponent - shared mixin methods for Chef-DSL files (recipes,
  #   attributes, definitions, resources, etc)
  #
  module CookbookComponent
    attr_reader   :name, :desc, :filename
    # the cookbook object this belongs to
    attr_reader   :cookbook
    attr_accessor :header_lines, :body_lines

    def initialize(cookbook, name, desc, filename, *args, &block)
      super(*args, &block)
      @cookbook = cookbook
      @name     = name
      @desc     = desc
      @filename = filename
    end

    def raw_lines
      begin
        @raw_lines ||= File.readlines(filename).map(&:chomp)
      rescue Errno::ENOENT => boom
        warn boom.to_s
        @raw_lines ||= []
      end
    end

    def read
      @header_lines = []
      @body_lines   = []
      # Gobble the header -- all comment lines following the first
      until raw_lines.first !~ /^#/ || raw_lines.empty?
        line = raw_lines.first
        header_lines << raw_lines.shift
        process_header_line(line)
      end
      # skip blank lines that follow the header
      until raw_lines.first =~ /\S+/ || raw_lines.empty?
        raw_lines.shift
      end
      raw_lines.each do |line|
        body_lines << line
        process_body_line(line)
      end
    end

    # called on each header line in #read
    def process_header_line(line)
      # override in subclass if you like
    end
    # called on each body line in #read
    def process_body_line(line)
      # override in subclass if you like
    end

    # save to {filename}.bak
    def dump
      File.open(filename+'.bak', 'w') do |f|
        f << header_lines.join("\n")
        f << "\n\n"
        f << body_lines.join("\n")
        f << "\n"
      end
    end

    # Use the chef from_file mixin -- instance_exec the file
    def execute!
      from_file(filename)
    end

    module ClassMethods
      def read(cookbook, name, desc, filename)
        attr_file = self.new(cookbook, name, desc, filename)
        attr_file.read
        attr_file
      end
    end
    def self.included(base) base.extend ClassMethods ; end
  end

  # ===========================================================================
  #
  # RecipeFile -- a chef recipe
  #
  class RecipeFile
    attr_accessor :copyright_lines, :author_lines, :include_recipes, :include_cookbooks
    include       CookbookComponent

    def initialize(*args, &block)
      super
      @include_recipes   = []
      @include_cookbooks = []
    end

    def process_header_line(line)
      self.author_lines    << "# Author::              #{$1}" if line =~ /^# Author::\s*(.*)/
      self.copyright_lines << line if line =~ /^# Copyright / && line !~ /YOUR_COMPANY_NAME/
    end

    def process_body_line(line)
      if line =~ /include_recipe\(?\s*[\"\']([^\"\'\:]*?)(::.*?)?[\"\']\s*\)?(?:[#;].*)?$/
        i_cb, i_rp = [$1, $2]
        i_rp = nil if i_rp == "default"
        self.include_cookbooks << i_cb
        self.include_recipes   << [i_cb, i_rp].compact.join("::")
      end
    end

    def read
      self.author_lines      = []
      self.copyright_lines   = []
      super
      self.copyright_lines   = ["# Copyright #{cookbook.copyright_text}"]         if copyright_lines.blank?
      self.author_lines      = ["# Author::              #{cookbook.maintainer}"] if author_lines.blank?
    end

    def dump
      super
    end

    def lint
      if self.name == 'default'
        sketchy = (include_recipes & %w[ runit::default java::sun ])
        if sketchy.present? then warn "Recipe #{cookbook.name}::#{name} includes #{sketchy.inspect} -- put these in component cookbooks, not the default." ; end
      end
    end

    def generate_header!
      new_header_lines = ['#']
      new_header_lines << "# Cookbook Name::       #{cookbook.name}"
      new_header_lines << "# Description::         #{desc.commentize}"
      new_header_lines << "# Recipe::              #{name}"
      new_header_lines += author_lines
      new_header_lines << "#"
      new_header_lines += copyright_lines
      new_header_lines << "#"
      new_header_lines << ("# "+cookbook.short_license_text.commentize) << '#'
      self.header_lines = new_header_lines
    end

  end

  # ===========================================================================
  #
  # AttributeFile -- a chef attribute file
  #
  # The metadata in here will be merged with anything found in the metadata.rb
  # file, with these winning
  #
  class AttributeFile
    include       Chef::Mixin::FromFile
    include       CookbookComponent
    attr_reader   :all_attributes

    def initialize(*args, &block)
      super(*args, &block)
      @all_attributes = DummyAttributeCollection.new
    end

    #
    # Fake the DSL so we can run the attributes file in our context
    #

    def default
      all_attributes
    end
    def set
      all_attributes
    end
    def attribute?(key) node.has_key?(key.to_sym) ; end
    def node
      {
        :hostname     => 'hostname',
        :cluster_name => :cluster_name,
        :platform     => 'ubuntu',   :platform_version => '10.4',
        :cloud        => { :private_ips => ['10.20.30.40']    },
        :cpu          => { :total => 2 }, :memory => { :total => 2 },
        :kernel       => { :os => '', :release => '', :machine => '' ,},
        :ec2          => { :instance_type => 'm1.large',      },
        :hbase        => { :home_dir => '/usr/lib/hbase',     },
        :zookeeper    => { :home_dir => '/usr/lib/zookeeper', },
        :redis        => { :slave => 'no' },
        :ipaddress    => '10.20.30.40',
        :languages    => { :ruby => { :version => "1.9" } },
        :cassandra    => { :mx4j_version => 'x.x' },
        :ganglia      => { :home_dir => '/var/lib/ganglia' },
      }.merge(@all_attributes)
    end
    def method_missing(meth, *args)
      if args.empty? && node.has_key?(meth)
        node[meth]
      else
        super(meth, *args)
      end
    end

    def value_for_platform(hsh)
      hsh["default"] || hsh[hsh.keys.first]
    end

  end

  # ===========================================================================
  #
  # CookbookMetadata -- the main deal. Unifies information from metadata.rb, the
  # attributes/ files, the rest of the tree; produces a synthesized metadata.rb
  # and README.md.
  #
  class CookbookMetadata < ClusterChef::DslObject
    include       Chef::Mixin::FromFile
    attr_reader   :dirname
    has_keys      :name, :author, :maintainer, :maintainer_email, :license, :version, :description, :long_desc_gen
    has_keys      :long_description
    attr_reader   :all_depends, :all_recipes, :all_attributes, :all_resources, :all_supports, :all_recommends
    attr_reader   :components, :attribute_files

    # also: grouping, conflicts, provides, replaces, recommends, suggests

    # definition: provides "here(:kitty, :time_to_eat)"
    # resource:   provides "service[snuggle]"

    def initialize(nm, dirname, *args, &block)
      super(*args, &block)
      name(nm)
      @dirname          = dirname
      @attribute_files  = {}
      @all_attributes   = CookbookMunger::DummyAttributeCollection.new
      @all_depends    ||= {}
      @all_recommends ||= {}
      @all_supports   ||= %w[ debian ubuntu ]
      @all_recipes    ||= {}
      @all_resources  ||= {}
      long_desc_gen(%Q{IO.read(File.join(File.dirname(__FILE__), 'README.md'))}) unless long_desc_gen
    end

    #
    # Fake DSL
    #

    # add dependency to list
    def depends(nm, ver=nil)    @all_depends[nm]    = (ver ? %Q{"#{nm}", "#{ver}"} : %Q{"#{nm}"} ) ; end
    # add recommended dependency to list
    def recommends(nm, ver=nil) @all_recommends[nm] = (ver ? %Q{"#{nm}", "#{ver}"} : %Q{"#{nm}"} ) ; end
    # add supported OS to list
    def supports(nm, ver=nil)   @all_supports      << nm ; @all_supports.uniq!   ; @all_supports ; end
    # add resource to list
    def resource(nm, desc)      @all_resources[nm]  = { :name => nm, :description => desc } ;   end

    # pull out the non-generated part of the README
    def long_description(val=nil)
      return @long_description.to_s if val.nil?
      lines = val.split(/\n/)
      until (not lines.last.blank?) || lines.empty? ; lines.pop ; end
      if lines.last =~ /^> readme generated by \[cluster_chef\]/
        # it's one of ours; strip out the generated material
        until (lines.first =~ /^## (Overview|Attributes)/) || lines.empty?
          lines.shift
        end
        desc = []
        lines.shift if lines.first =~ /^## (Overview)/
        until (lines.first =~ /^## Attributes/) || lines.empty?
          desc << lines.shift
        end
      else
        desc = lines
      end
      @long_description = desc.join("\n").strip
    end


    # add attribute to list
    def attribute(nm, info={})
      return if info[:type] == 'hash'
      path_segs = nm.split("/")
      leaf      = path_segs.pop
      attr_branch = @all_attributes
      path_segs.each{|seg| attr_branch = attr_branch[seg] }
      if info.present? || (not attr_branch.has_key?(leaf))
        attr_branch[leaf] = CookbookMunger::DummyAttribute.new(nm, info)
      end
      attr_branch[leaf]
    end

    # add recipe to list
    def recipe(recipe_name, desc=nil)
      recipe_name = 'default' if recipe_name == name
      recipe_name = recipe_name.gsub(/^#{name}::/, "")
      #
      desc        = (recipe_name == 'default' ? "Base configuration for #{name}" : recipe_name.titleize) if (desc.blank? || desc == recipe_name.titleize)
      filename    = file_in_cookbook("recipes/#{recipe_name}.rb")
      @all_recipes[recipe_name]      ||= RecipeFile.read(self, recipe_name, desc, filename)
      @all_recipes[recipe_name].desc ||= desc if desc.present?
      @all_recipes[recipe_name]
    end

    #
    # Read project
    #

    def file_in_cookbook(filename)
      File.expand_path(filename, self.dirname)
    end

    def load_components
      from_file(file_in_cookbook("metadata.rb"))

      @components = {
        :attributes  => Dir[file_in_cookbook('attributes/*.rb')   ].map{|f| nm = File.basename(f, '.rb') ; AttributeFile.read(self, nm, "attributes[#{self.name}::#{nm}", f) },
        :recipes     => Dir[file_in_cookbook('recipes/*.rb')      ].map{|f| nm = File.basename(f, '.rb') ; recipe("#{name}::#{nm}") },
        :resources   => Dir[file_in_cookbook('resources/*.rb')    ].map{|f| File.basename(f, '.rb') },
        :providers   => Dir[file_in_cookbook('providers/*.rb')    ].map{|f| File.basename(f, '.rb') },
        :templates   => Dir[file_in_cookbook('templates/**/*.rb') ].map{|f| File.join(File.basename(File.dirname(f)), File.basename(f, '.rb')) },
        :definitions => Dir[file_in_cookbook('definitions/*.rb')  ].map{|f| File.basename(f, '.rb') },
        :libraries   => Dir[file_in_cookbook('definitions/*.rb')  ].map{|f| File.basename(f, '.rb') },
      }

      components[:attributes].each do |attrib_file|
        merge_attribute_file(attrib_file)
      end
    end

    def merge_attribute_file(attrib_file)
      attrib_file.execute!
      attrib_file.all_attributes.attrs.each do |af_attrib|
        my_attrib = attribute(af_attrib.name)
        my_attrib.merge!(af_attrib.to_hash)
      end
    end

    def lint!
      # Settings.each do |attr, sval|
      #   my_val = self.send(attr) rescue nil
      #   warn([name, attr, sval, my_val ]) unless sval == my_val
      # end
      lint_dependencies
      lint_presence
      components[:recipes].each(&:lint)
    end

    def lint_dependencies
      include_cookbooks = []
      components[:recipes].each do |recipe|
        include_cookbooks += recipe.include_cookbooks
      end
      include_cookbooks = include_cookbooks.sort.uniq
      missing_dependencies = (include_cookbooks  - all_depends.keys - [name])
      missing_includes     = (all_depends.keys - include_cookbooks  - [name])
      warn "Coookbook #{name} doesn't declare dependency on #{missing_dependencies.join(", ")}, but has an include_recipe that refers to it" if missing_dependencies.present?
      warn "Coookbook #{name} declares dependency on #{missing_includes.join(", ")}, but never calls include_recipe with it"             if missing_includes.present?
    end

    def lint_presence
      components[:recipes].each do |recipe|
        warn "Recipe #{name}::#{recipe.name} #{recipe.filename} missing, though it is alluded to in #{name}/metadata.rb" unless File.exists?(recipe.filename)
      end
    end

    def dump
      load_components
      lint!
      File.open(file_in_cookbook('metadata.rb.bak'), 'w') do |f|
        f << render('metadata.rb')
      end
      File.open(file_in_cookbook('README.md.bak'), 'w') do |f|
        f << render('README.md')
      end
      components[:recipes].each do |recipe|
        recipe.generate_header!
        recipe.dump
      end
    end

    #
    # Content
    #

    def self.licenses
      return @licenses if @licenses
      @licenses = YAML.load(File.read(File.expand_path("licenses.yaml", CookbookMunger::TEMPLATE_ROOT)))
    end

    def license_info
      @license_info = self.class.licenses.values.detect{|lic| lic[:name] == license }
    end

    def short_license_text
      license_info ? license_info[:short] : '(no license specified)'
    end

    def copyright_text
      "2011, #{maintainer}"
    end

    #
    # Display
    #

    def render(filename)
      self.class.template(filename).result(self.send(:binding))
    end

    def self.template(filename)
      template_text = File.read(File.expand_path("#{filename}.erb", CookbookMunger::TEMPLATE_ROOT))
      Erubis::Eruby.new(template_text)
    end
  end

  puts "-----------------------------------------------------------------------"
  puts "\n\n++++++++++++++++ COOKBOOK MUNGE NOM NOM NOM +++++++++++++++++++\n\n"
  Settings.cookbook_paths.each do |cookbook_path|

    Dir["#{cookbook_path}/*/metadata.rb"].each do |f|
      dirname = File.dirname(f)
      nm      = File.basename(dirname)
      puts "====== %-20s ====================" % nm
      cookbook_metadata = CookbookMetadata.new(nm, dirname, Settings.dup)
      cookbook_metadata.dump
    end
  end

end