#encoding: utf-8
require 'json'
require 'digest/sha2'

require 'chef'
require 'chef/mixin/deep_merge'

require 'vagabond/helpers'
require 'vagabond/constants'
require 'vagabond/version'
require 'vagabond/notify_mash'

module Vagabond
  class InternalConfiguration

    DEFAULT_ERCHEF_VERSION = '11.0.8'
    
    class << self
      attr_accessor :host_provisioned

      def host_provisioned?
        !!@host_provisioned
      end
    end

    include Helpers
    
    attr_reader :config
    attr_reader :ui
    attr_reader :options
    attr_accessor :force_bases
    
    def initialize(vagabondfile, ui, options, args={})
      @vagabondfile = vagabondfile
      @checksums = Mash.new
      if(ui)
        @ui = ui
      else
        @ui = Logger.new('/dev/null')
        @ui.instance_eval do
          def color(*args)
          end
        end
      end
      @options = options
      create_store
      load_existing
      @config = storage_hash(
        Mash.new(
          :mappings => {},
          :template_mappings => {},
          :test_mappings => {},
          :spec_mappings => {},
          :spec_clusters => {}
        ).merge(config)
      )
      @force_bases = args[:force_bases] || []
      ensure_state
      make_knife_config_if_required
    end

    def ensure_state
      unless(self.class.host_provisioned?)
        install_cookbooks
      end
      check_bases_and_customs!
      set_templates
      store_checksums
      write_dna_json
      write_solo_rb
      if(solo_needed?)
        run_solo
      else
        self.class.host_provisioned = true
      end
    end

    def set_templates
      unless(Vagabond.const_defined?(:BASE_TEMPLATES))
        Vagabond.const_set(
          :BASE_TEMPLATES, cookbook_attributes(:vagabond).bases.keys
        )
      end
    end

    def check_bases_and_customs!
      if(File.exists?(dna_path))
        dna = Mash.new(JSON.load(File.read(dna_path)))
        %w(bases customs).each do |key|
          if(dna[:vagabond][key])
            dna[:vagabond][key].each do |n, opts|
              options[:force_solo] = true unless Lxc.new(n).exists?
            end
          end
        end
        if(dna[:vagabond][:server])
          srv_name = [
            cookbook_attributes(:vagabond).server.prefix,
            dna[:vagabond][:server][:erchefs].first.to_s.gsub('.', '_')
          ].join
          options[:force_solo] = true unless Lxc.new(srv_name).exists?
        end
        unless(Lxc.new(cookbook_attributes(:vagabond).server.zero_lxc_name).exists?)
          options[:force_solo] = true
        end
      end
    end

    def [](k)
      @config[k]
    end

    def []=(k,v)
      if(v.is_a?(Hash))
        v = storage_hash(v)
      end
      @config[k] = v
      save
    end
    
    def create_store
      FileUtils.mkdir_p(store_path)
    end

    def load_existing(file=nil)
      if(File.exists?(path = File.join(store_path, 'vagabond.json')))
        if(file)
          file.rewind
          content = file.read
        else
          content = File.read(path)
        end
        if(content.strip.empty?)
          config = Mash.new
        else
          config = Mash.new(
            JSON.load(content)
          )
        end
        @config = storage_hash(Chef::Mixin::DeepMerge.merge(config, @config))
      else
        @config = storage_hash
      end
    end

    def store_path
      path = File.join(File.dirname(@vagabondfile.store_path), '.vagabond')
      unless(File.directory?(path))
        FileUtils.mkdir_p(path)
      end
      path
    end

    def dna_path
      File.join(store_path, 'dna.json')
    end

    def solo_path
      File.join(store_path, 'solo.rb')
    end

    def write_dna_json
      conf = Mash.new(:bases => Mash.new, :customs => Mash.new)
      (Array(@vagabondfile[:nodes]).map(&:last).map{|i| i[:template]}.compact + Array(force_bases)).uniq.each do |t|
        conf[:bases][t] = Mash.new(:enabled => true) if Vagabond::BASE_TEMPLATES.include?(t.to_s)
      end
      Array(@vagabondfile[:templates]).each do |t_name, opts|
        if(Vagabond::BASE_TEMPLATES.include?(opts[:base].to_s))
          conf[:bases][opts[:base]] = Mash.new(:enabled => true)
          if(opts.has_key?(:memory) && !opts[:memory].is_a?(Hash))
            opts[:memory][:ram] = opts[:memory].to_s
          end
          conf[:customs][generated_name(t_name)] = opts
          config[:template_mappings][t_name] = generated_name(t_name)
        else
          ui.fatal "Invalid base template encountered: #{t_name}"
          ui.info ui.color("  -> Valid base templates: #{BASE_TEMPLATES.sort.join(', ')}", :red)
          raise VagabondError::InvalidBaseTemplate.new(t_name)
        end
      end
      if(@vagabondfile.local_chef_server? && !@vagabondfile[:local_chef_server][:zero])
        version = cookbook_attributes(:vagabond).server.erchefs.dup || []
        version.push @vagabondfile[:local_chef_server][:version] || DEFAULT_ERCHEF_VERSION
        conf[:server] = Mash.new
        conf[:server][:erchefs] = [version].flatten.uniq
      end
      conf[:host_cookbook_store] = cookbook_path
      File.open(dna_path, 'w') do |file|
        file.write(
          JSON.dump(
            :vagabond => conf,
            :run_list => %w(recipe[vagabond])
          )
        )
      end
      save
    end

    def write_solo_rb
      File.open(solo_path, 'w') do |file|
        file.write("\nfile_cache_path \"#{cache_path}\"\ncookbook_path \"#{cookbook_path}\"\n")
      end
    end

    def store_checksums
      [dna_path, solo_path].each do |path|
        @checksums[path] = get_checksum(path)
      end
    end

    def get_checksum(path)
      if(File.exists?(path))
        s = Digest::SHA256.new
        s << File.read(path)
        s.hexdigest
      else
        ''
      end
    end

    def solo_needed?
      if(options[:force_solo])
        true
      elsif(options[:disable_solo])
        false
      else
        [dna_path, solo_path].detect do |path|
          @checksums[path] != get_checksum(path)
        end
      end
    end
    
    def cache_path
      unless(@cache_path)
        FileUtils.mkdir_p(@cache_path = File.join(store_path, 'chef_cache'))
      end
      @cache_path
    end

    def cookbook_path
      unless(@cookbook_path)
        FileUtils.mkdir_p(@cookbook_path = File.join(store_path, 'cookbooks'))
      end
      @cookbook_path
    end

    def cheffile_path
      File.expand_path(
        File.join(
          File.dirname(__FILE__),
          running_development_vagabond? ? 'Cheffile.dev' : 'Cheffile'
        )
      )
    end

    def vendor_cheffile_path
      File.expand_path(
        File.join(File.dirname(cookbook_path), 'Cheffile')
      )
    end
    
    def cookbook_vendor_required?
      need_vendor = !File.exists?(vendor_cheffile_path)
      need_vendor ||= get_checksum(vendor_cheffile_path) != get_checksum(cheffile_path)
      spec = Gem::Specification.find_by_name('vagabond', ::Vagabond::VERSION.version)
      if(running_development_vagabond?)
        if(self[:dev_mode] && self[:dev_mode][:vendor_cookbook_check])
          elapsed_time = Time.now.to_i - self[:dev_mode][:vendor_cookbook_check].to_i
          need_vendor = elapsed_time > (ENV['VAGABOND_DEV_VENDOR_EVERY'] || 3600).to_i
        end
        self[:dev_mode] ||= Mash.new
        self[:dev_mode][:vendor_cookbook_check] = Time.now.to_i if need_vendor
      end
      unless(ENV['VAGABOND_FORCE_VENDOR'].to_s == 'false')
        ENV['VAGABOND_FORCE_VENDOR'] || need_vendor
      end
    end

    def running_development_vagabond?
      spec = Gem::Specification.find_by_name('vagabond', ::Vagabond::VERSION.version)
      ::Vagabond::VERSION.segments.last.odd?
    end
      
    def install_cookbooks
      begin
        if(cookbook_vendor_required?)
          FileUtils.copy(cheffile_path, vendor_cheffile_path)
          ui.info ui.color('Fetching required cookbooks...', :yellow)
          cmd = build_command('librarian-chef update', :shellout => {:cwd => File.dirname(cookbook_path)})
          cmd.run_command
          cmd.error!
          ui.info ui.color('  -> COMPLETE!', :yellow)
        else
          cmd = build_command('librarian-chef install', :shellout => {:cwd => File.dirname(cookbook_path)})
          cmd.run_command
          cmd.error!
        end
      rescue => e
        ui.info e.to_s
        ui.info ui.color('  -> FAILED!', :red, :bold)
        raise VagabondError::LibrarianHostInstallFailed.new(e)
      end
    end
    
    def run_solo
      unless(self.class.host_provisioned?)
        begin
          ui.info ui.color('Ensuring expected system state (creating required base containers)', :yellow)
          ui.info ui.color('   - This can take a while on first run or new templates...', :yellow)
          cmd = build_command("chef-solo -j #{File.join(store_path, 'dna.json')} -c #{File.join(store_path, 'solo.rb')}", :sudo => true)
          cmd.run_command
          cmd.error!
          ui.info ui.color('  -> COMPLETE!', :yellow)
          self.class.host_provisioned = true
        rescue => e
          ui.info e.to_s
          FileUtils.rm(solo_path)
          ui.info ui.color('  -> FAILED!', :red, :bold)
          raise VagabondError::HostProvisionFailed.new(e)
        end
      end
    end

    def config_path
      File.join(store_path, 'vagabond.json')
    end
    
    def save
      mode = File.exists?(config_path) ? 'r+' : 'w+'
      File.open(config_path, mode) do |file|
        file.flock(File::LOCK_EX)
        file.rewind
        if(sha = file_changed?(file.path))
          @checksums[file.path] = sha
          load_existing(file)
        end
        file.rewind
        file.write(JSON.pretty_generate(@config))
        file.truncate(file.pos)
      end
    end

    def knife_config_available?
      if(File.exists?(File.join(store_path, 'knife.rb')))
        false
      else
        cwd = @vagabondfile.directory.split('/')
        found = false
        until(found || cwd.empty?)
          found = File.exists?(File.join(*(cwd + ['.chef/knife.rb'])))
          cwd.pop
        end
        found
      end
    end

    def file_changed?(path)
      checksum = get_checksum(path)
      checksum unless @checksums[path] == checksum
    end

    def storage_hash(hsh={})
      new_hash = {}
      hsh.each_pair do |k,v|
        if(v.is_a?(Hash))
          new_hash[k] = storage_hash(v)
        else
          new_hash[k] = v
        end
      end
      n = NotifyMash.new(new_hash)
      n.add_notification do
        save
      end
      n
    end
    
    def cookbook_attributes(cookbook, namespace=true)
      @_attr_cache ||= Mash.new
      unless(@_attr_cache[cookbook])
        node = Chef::Node.new
        %w(rb json js).each do |ext|
          Dir.glob(File.join(cookbook_path, cookbook.to_s, 'attributes', "*.#{ext}")).each do |attr_file|
            node.from_file(attr_file)
          end
        end
        if(namespace)
          key = namespace.is_a?(String) || namespace.is_a?(Symbol) ? namespace : cookbook
          @_attr_cache[cookbook] = node.attributes.send(key)
        else
          @_attr_cache[cookbook] = node.attributes
        end
      end
      @_attr_cache[cookbook]
    end
    
    def make_knife_config_if_required(force=false)
      if(@vagabondfile.local_chef_server? || force)
        unless(knife_config_available?)
          store_dir = File.dirname(store_path)
          k_dir = File.join(store_dir, '.chef')
          FileUtils.mkdir_p(k_dir)
          unless(File.exists?(knife = File.join(k_dir, 'knife.rb')))
            File.open(knife, 'w') do |file|
              file.write <<-EOF
node_name 'dummy'
client_key File.join(File.dirname(__FILE__), 'client.pem')
validation_client_name 'dummy-validator'
validation_key File.join(File.dirname(__FILE__), 'validation.pem')
cookbook_path ['#{%w(cookbooks site-cookbooks).map{|dir|File.join(@vagabondfile.directory, dir)}.join(',')}']
EOF
            end
          end
          %w(client.pem validation.pem).each do |name|
            unless(File.exists?(pem = File.join(k_dir, name)))
              %x{openssl genrsa -out #{pem} 2048}
            end
          end
        end
      end
    end
  end
end
