require 'ostruct' require 'json' require 'solve'

# Taken from stackoverflow.com/a/25990044 class ::Hash

def deep_merge(second)
  merger = proc { |key, v1, v2|
    Hash === v1 && Hash === v2 ?
      v1.merge(v2, &merger) :
      [:undefined, nil, :nil].include?(v2) ? v1 : v2
  }
  self.merge(second, &merger)
end

end

module Berktacular

# This class represents a Berksfile

class Berksfile

  # @!attribute [r] name
  #   @return [String] the name of the environment.
  # @!attribute [r] description
  #   @return [String] a description of the enviroment.
  # @!attribute [r] installed
  #   @return [Hash] a hash of installed cookbook directories.
  # @!attribute [r] missing_deps
  #   @return [Hash] a hash of cookbooks missing dependencies after calling verify.
  attr_reader :name, :description, :installed, :missing_deps

  # Creates a new Berksfile from a chef environment file.
  #
  # @param environment [Hash] a parsed JSON chef environment config.
  # @option opts [String] :github_token (nil) the github token to use.
  # @option opts [True,False] :upgrade (False) whether or not to check for upgraded cookbooks.
  # @option opts [True,False] :verbose (False) be more verbose.
  # @option opts [Array<String>] :source_list additional Berkshelf API sources to include in the
  #   generated Berksfile.
  def initialize( env_path, opts = {})
    @opts = {
      upgrade:       opts.has_key?(:upgrade)      ? opts[:upgrade]      : false,
      github_token:  opts.has_key?(:github_token) ? opts[:github_token] : nil,
      verbose:       opts.has_key?(:verbose)      ? opts[:verbose]      : false,
      source_list:   opts.has_key?(:source_list)  ? opts[:source_list]  : [],
      multi_cookbook_dir:  opts.has_key?(:multi_cookbook_dir) ? opts[:multi_cookbook_dir] : nil,
      versions_only:  opts.has_key?(:versions_only) ? opts[:versions_only] : false,
      max_depth:      opts.has_key?(:max_depth) ? opts[:max_depth] : 10
    }
    @counter  = 0
    @env_hash =  expand_env_file(env_path)

    @name               = @env_hash['name']               || nil
    @description        = @env_hash['description']        || nil
    @cookbook_versions  = @env_hash['cookbook_versions']  || {}
    @cookbook_locations = @env_hash['cookbook_locations'] || {}
    @installed = {}
    # only connect once, pass the client to each cookbook.  and only if needed
    connect_to_git if @opts[:upgrade]
    @opts[:source_list] = (@opts[:source_list] + ["https://supermarket.chef.io"]).uniq
  end

  # @return [Hash] representation of the env_file.
  def env_file
    if @opts[:upgrade]
      cookbooks.each do |book|
        @env_hash['cookbook_versions'][book.name] = book.version_specifier
      end
    end
    @env_hash
  end

  # @return [String] representation of the env_file in pretty json.
  def env_file_json
    if @opts[:upgrade]
      cookbooks.each do |book|
        @env_hash['cookbook_versions'][book.name] = book.version_specifier
      end
    end
    JSON.pretty_generate(@env_hash)
  end

  # @param workdir [String] the directory in which to install.  If nil, Berktacular.best_temp_dir is used.
  # @return [String] the directory path where the cookbooks were installed.
  def install(workdir = nil)
    if workdir
      FileUtils.mkdir_p(workdir)
    else
      workdir = Berktacular.best_temp_dir
    end
    unless @installed[workdir]
      # remove the Berksfile.lock if it exists (it shouldn't).
      berksfile = File.join(workdir, "Berksfile")
      lck       = berksfile + ".lock"
      cookbooks = File.join(workdir, "cookbooks")
      FileUtils.rm(lck) if File.exists? lck
      File.write(berksfile, self)
      Berktacular.run_command("berks vendor -d --berksfile #{berksfile} #{cookbooks}")
      @installed[workdir] = {berksfile: berksfile, lck: lck, cookbooks: cookbooks}
    end
    workdir
  end

  # @params workdir [String] the directory in which to install.  If nil, Berktacular.best_temp_dir is used.
  # @return [True,False] the status of the verify.
  def verify(workdir = nil)
    require 'ridley'
    @missing_deps = {}
    workdir       = install(workdir)
    versions      = {}
    dependencies  = {}
    Dir["#{@installed[workdir][:cookbooks]}/*"].each do |cookbook_dir|
      next unless File.directory?(cookbook_dir)
      metadata_candidates = ['rb', 'json'].map {|ext| File.join(cookbook_dir, "metadata.#{ext}") }
      metadata_path = metadata_candidates.find {|f| File.exists?(f) }
      raise "Metadata file not found: #{metadata_candidates}" if metadata_path.nil?
      metadata =
        metadata_path =~ /\.json$/ ? metadata_from_json(IO.read(metadata_path)) :
          Ridley::Chef::Cookbook::Metadata.from_file(metadata_path)
      cookbook_name = metadata.name
      name_from_path = File.basename(cookbook_dir)
      unless cookbook_name == name_from_path
        if cookbook_name.empty?
          puts "Cookbook #{name_from_path} has no name specified in metadata.rb"
          cookbook_name = name_from_path
        else
          warn "Cookbook name from metadata.rb does not match the directory name!",
               "metadata.rb: '#{cookbook_name}'",
               "cookbook directory name: '#{name_from_path}'"
        end
      end
      versions[cookbook_name] = metadata.version
      dependencies[cookbook_name] = metadata.dependencies
    end
    errors = false
    dependencies.each do |name, deps|
      deps.each do |dep_name, constraint|
        actual_version = versions[dep_name]
        if !actual_version
          @missing_deps[name] = "#{name}-#{versions[name]} depends on #{dep_name} which was not installed!"
          warn @missing_deps[name]
          errors = true
        elsif constraint != []  # some cookbooks have '[]' as a dependency in their json metadata
          constraint_obj = begin
            Semverse::Constraint.new(constraint)
          rescue Semverse::InvalidConstraintFormat => ex
            warn "Could not parse version constraint '#{constraint}' " +
                 "for dependency '#{dep_name}' of cookbook '#{name}'"
            raise ex
          end

          unless constraint_obj.satisfies?(actual_version)
            @missing_deps[name] = "#{name}-#{versions[name]} depends on #{dep_name} #{constraint} but #{dep_name} is #{actual_version}!"
            warn @missing_deps[name]
            errors = true
          end
        end
      end
    end
    !errors
  end

  # @param berks_conf [String] path to the berkshelf config file to use.
  # @param knife_conf [String] path to the knife config file to use.
  # @param workdir [String] Path to use as the working directory.
  #   @default Berktacular.best_temp_dir
  # @return [True] or raise on error.
  def upload(berks_conf, knife_conf, workdir=nil)
    raise "No berks config, required for upload" unless berks_conf && File.exists?(berks_conf)
    raise "No knife config, required for upload" unless knife_conf && File.exists?(knife_conf)
    workdir       = install(workdir)
    new_env_file  = File.join(workdir, @name + ".json")
    File.write(new_env_file, env_file_json)
    Berktacular.run_command("berks upload --berksfile #{@installed[workdir][:berksfile]} -c #{berks_conf}")
    Berktacular.run_command("knife environment from file #{new_env_file} -c #{knife_conf}")
  end

  # param workdir [String,nil] the workdir to remove.  If nil, remove all installed working directories.
  def clean(workdir = nil)
    if workdir
      Fileutils.rm_r(workdir)
      @installed.delete(workdir)
    else
      # clean them all
      @installed.keys.each { |d| FileUtils.rm_r(d) }
      @installed = {}
    end
  end

  # @param [IO] where to write the data.
  def print_berksfile( io = STDOUT )
    io.puts to_s
  end

  # @return [String] the berksfile as a String object
  def to_s
    str = ''
    str << "# Name: '#{@name}'\n" if @name
    str << "# Description: #{@description}\n\n" if @description
    str << "# This file is auto-generated, changes will be overwritten\n"
    str << "# Modify the .json environment file and regenerate this Berksfile to make changes.\n\n"

    @opts[:source_list].each do |source_url|
      str << "source '#{source_url}'\n"
    end
    str << "\n"
    cookbooks.each { |l| str << l.to_s << "\n" }
    str
  end

  # @return [Array] a list of Cookbook objects for this environment.
  def cookbooks
    @cookbooks ||= @cookbook_versions.sort.map do |book, version|
      Cookbook.new(book, version, @cookbook_locations[book], @opts)
    end
  end

  # print out the cookbooks that have newer version available on github.
  def check_updates
    connect_to_git
    cookbooks.each do |b|
      candidates = b.check_updates
      next unless candidates.any?
      puts "Cookbook: #{b.name} (auto upgrade: #{b.auto_upgrade ? 'enabled' : 'disabled'})",
           "\tCurrent: #{b.version_number}",
           "\tUpdates: #{candidates.join(", ")}"
    end
  end

  private

  # connect to github using the token in @opts[:github_token].
  # @return [Octokit::Client] a connected github client.
  def connect_to_git
    raise "No token given, can't connect to git" unless @opts[:github_token]
    puts "Connecting to git with supplied github_token" if @opts[:verbose]
    require 'octokit'
    @opts[:git_client] ||= Octokit::Client.new(
      access_token: @opts[:github_token],
      auto_paginate: true
    )
  end

  # recursively expand env_file @opts[:max_depth] times.
  # @return [Hash] of merged env_file
  def expand_env_file(env_file)
    raise "Exceeded max depth!" if @counter > @opts[:max_depth]
    @counter += 1
    env = {}
    if File.exists?(env_file)
      env = JSON.parse( File.read(env_file) )
    else
      raise "Environment file '#{env_file}' does not exist!"
    end
    if env.has_key?("parent")
      parent = env["parent"]
      if !File.exists?(parent)
        parent = File.join(
          File.dirname(env_file),
          parent
        )
      end
      env = expand_env_file( parent ).deep_merge( env )
    end
    env
  end

  def metadata_from_json(json_str)
    OpenStruct.new(JSON.parse(json_str))
  end

end

end