require 'fileutils'
require 'timeout'
require 'open3'

require_relative 'gitlab_config'
require_relative 'gitlab_logger'

class GitlabProjects
  GLOBAL_HOOKS_DIRECTORY = File.join(ROOT_PATH, 'hooks')

  # Project name is a directory name for repository with .git at the end
  # It may be namespaced or not. Like repo.git or gitlab/repo.git
  attr_reader :project_name

  # Absolute path to directory where repositories stored
  # By default it is /home/git/repositories
  attr_reader :repos_path

  # Full path is an absolute path to the repository
  # Ex /home/git/repositories/test.git
  attr_reader :full_path

  def self.create_hooks(path)
    local_hooks_directory = File.join(path, 'hooks')
    real_local_hooks_directory = :not_found
    begin
      real_local_hooks_directory = File.realpath(local_hooks_directory)
    rescue Errno::ENOENT
      # real_local_hooks_directory == :not_found
    end

    if real_local_hooks_directory != File.realpath(GLOBAL_HOOKS_DIRECTORY)
      if File.exist?(local_hooks_directory)
        $logger.info "Moving existing hooks directory and symlinking global hooks directory for #{path}."
        FileUtils.mv(local_hooks_directory, "#{local_hooks_directory}.old.#{Time.now.to_i}")
      end
      FileUtils.ln_sf(GLOBAL_HOOKS_DIRECTORY, local_hooks_directory)
    else
      $logger.info "Hooks already exist for #{path}."
      true
    end
  end

  def initialize
    @command = ARGV.shift
    @project_name = ARGV.shift
    @repos_path = GitlabConfig.new.repos_path
    @full_path = File.join(@repos_path, @project_name) unless @project_name.nil?
  end

  def exec
    case @command
    when 'create-branch'; create_branch
    when 'rm-branch'; rm_branch
    when 'create-tag'; create_tag
    when 'rm-tag'; rm_tag
    when 'add-project'; add_project
    when 'list-projects'; puts list_projects
    when 'rm-project'; rm_project
    when 'mv-project'; mv_project
    when 'import-project'; import_project
    when 'fork-project'; fork_project
    when 'fetch-remote'; fetch_remote
    when 'update-head'; update_head
    when 'push-branches'; push_branches
    when 'delete-remote-branches'; delete_remote_branches
    when 'list-remote-tags'; list_remote_tags
    when 'gc'; gc
    else
      $logger.warn "Attempt to execute invalid gitlab-projects command #{@command.inspect}."
      puts 'not allowed'
      false
    end
  end

  protected

  def list_remote_tags
    remote_name = ARGV.shift

    tag_list, exit_code, error = nil
    cmd = %W(git --git-dir=#{full_path} ls-remote --tags #{remote_name})

    Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
      tag_list  = stdout.read
      error     = stderr.read
      exit_code = wait_thr.value.exitstatus
    end

    if exit_code.zero?
      puts tag_list
      true
    else
      puts error
      false
    end
  end

  def push_branches
    remote_name = ARGV.shift

    $logger.info "Pushing branches from #{full_path} to remote #{remote_name}: #{ARGV}"
    cmd = %W(git --git-dir=#{full_path} push -- #{remote_name}).concat(ARGV)
    pid = Process.spawn(*cmd)

    begin
      Process.wait(pid)

      $?.exitstatus.zero?
    rescue => exception
      $logger.error "Pushing branches to remote #{remote_name} failed due to: #{exception.message}"

      Process.kill('KILL', pid)
      Process.wait
      false
    end
  end

  def delete_remote_branches
    remote_name = ARGV.shift
    branches = ARGV.map { |branch_name| ":#{branch_name}" }

    $logger.info "Pushing deleted branches from #{full_path} to remote #{remote_name}: #{ARGV}"
    cmd = %W(git --git-dir=#{full_path} push -- #{remote_name}).concat(branches)
    pid = Process.spawn(*cmd)

    begin
      Process.wait(pid)

      $?.exitstatus.zero?
    rescue => exception
      $logger.error "Pushing deleted branches to remote #{remote_name} failed due to: #{exception.message}"

      Process.kill('KILL', pid)
      Process.wait
      false
    end
  end

  def create_branch
    branch_name = ARGV.shift
    ref = ARGV.shift || "HEAD"
    cmd = %W(git --git-dir=#{full_path} branch -- #{branch_name} #{ref})
    system(*cmd)
  end

  def rm_branch
    branch_name = ARGV.shift
    cmd = %W(git --git-dir=#{full_path} branch -D -- #{branch_name})
    system(*cmd)
  end

  def create_tag
    tag_name = ARGV.shift
    ref = ARGV.shift || "HEAD"
    cmd = %W(git --git-dir=#{full_path} tag)
    if ARGV.size > 0
      msg = ARGV.shift
      cmd += %W(-a -m #{msg})
    end
    cmd += %W(-- #{tag_name} #{ref})
    system(*cmd)
  end

  def rm_tag
    tag_name = ARGV.shift
    cmd = %W(git --git-dir=#{full_path} tag -d -- #{tag_name})
    system(*cmd)
  end

  def add_project
    $logger.info "Adding project #{@project_name} at <#{full_path}>."
    FileUtils.mkdir_p(full_path, mode: 0770)
    cmd = %W(git --git-dir=#{full_path} init --bare)
    system(*cmd) && self.class.create_hooks(full_path)
  end

  def list_projects
    $logger.info 'Listing projects'
    Dir.chdir(repos_path) do
      next Dir.glob('**/*.git')
    end
  end

  def rm_project
    $logger.info "Removing project #{@project_name} from <#{full_path}>."
    FileUtils.rm_rf(full_path)
  end

  def mask_password_in_url(url)
    result = URI(url)
    result.password = "*****" unless result.password.nil?
    result.user = "*****" unless result.user.nil? #it's needed for oauth access_token
    result
  rescue
    url
  end

  def fetch_remote
    @name = ARGV.shift

    # timeout for fetch
    timeout = (ARGV.shift || 120).to_i

    # fetch with --force ?
    forced = ARGV.include?('--force')

    # fetch with --tags or --no-tags
    tags_option = ARGV.include?('--no-tags') ? '--no-tags' : '--tags'

    $logger.info "Fetching remote #{@name} for project #{@project_name}."
    cmd = %W(git --git-dir=#{full_path} fetch #{@name})
    cmd << '--force' if forced
    cmd << tags_option
    pid = Process.spawn(*cmd)

    begin
      Timeout.timeout(timeout) do
        Process.wait(pid)
      end

      $?.exitstatus.zero?
    rescue Timeout::Error
      $logger.error "Fetching remote #{@name} for project #{@project_name} failed due to timeout."

      Process.kill('KILL', pid)
      Process.wait
      false
    end
  end

  def remove_origin_in_repo
    cmd = %W(git --git-dir=#{full_path} remote rm origin)
    pid = Process.spawn(*cmd)
    Process.wait(pid)
  end

  # Import project via git clone --bare
  # URL must be publicly cloneable
  def import_project
    # Skip import if repo already exists
    return false if File.exists?(full_path)

    @source = ARGV.shift
    masked_source = mask_password_in_url(@source)

    # timeout for clone
    timeout = (ARGV.shift || 120).to_i
    $logger.info "Importing project #{@project_name} from <#{masked_source}> to <#{full_path}>."
    cmd = %W(git clone --bare -- #{@source} #{full_path})

    pid = Process.spawn(*cmd)

    begin
      Timeout.timeout(timeout) do
        Process.wait(pid)
      end

      return false unless $?.exitstatus.zero?
    rescue Timeout::Error
      $logger.error "Importing project #{@project_name} from <#{masked_source}> failed due to timeout."

      Process.kill('KILL', pid)
      Process.wait
      FileUtils.rm_rf(full_path)
      return false
    end

    self.class.create_hooks(full_path)
    # The project was imported successfully.
    # Remove the origin URL since it may contain password.
    remove_origin_in_repo

    true
  end

  # Move repository from one directory to another
  #
  # Ex.
  #  gitlab.git -> gitlabhq.git
  #  gitlab/gitlab-ci.git -> randx/six.git
  #
  # Wont work if target namespace directory does not exist
  #
  def mv_project
    new_path = ARGV.shift

    unless new_path
      $logger.error "mv-project failed: no destination path provided."
      return false
    end

    new_full_path = File.join(repos_path, new_path)

    # verify that the source repo exists
    unless File.exists?(full_path)
      $logger.error "mv-project failed: source path <#{full_path}> does not exist."
      return false
    end

    # ...and that the target repo does not exist
    if File.exists?(new_full_path)
      $logger.error "mv-project failed: destination path <#{new_full_path}> already exists."
      return false
    end

    $logger.info "Moving project #{@project_name} from <#{full_path}> to <#{new_full_path}>."
    FileUtils.mv(full_path, new_full_path)
  end

  def fork_project
    new_namespace = ARGV.shift

    # destination namespace must be provided
    unless new_namespace
      $logger.error "fork-project failed: no destination namespace provided."
      return false
    end

    # destination namespace must exist
    namespaced_path = File.join(repos_path, new_namespace)
    unless File.exists?(namespaced_path)
      $logger.error "fork-project failed: destination namespace <#{namespaced_path}> does not exist."
      return false
    end

    # a project of the same name cannot already be within the destination namespace
    full_destination_path = File.join(namespaced_path, project_name.split('/')[-1])
    if File.exists?(full_destination_path)
      $logger.error "fork-project failed: destination repository <#{full_destination_path}> already exists."
      return false
    end

    $logger.info "Forking project from <#{full_path}> to <#{full_destination_path}>."
    cmd = %W(git clone --bare -- #{full_path} #{full_destination_path})
    system(*cmd) && self.class.create_hooks(full_destination_path)
  end

  def update_head
    new_head = ARGV.shift

    unless new_head
      $logger.error "update-head failed: no branch provided."
      return false
    end

    File.open(File.join(full_path, 'HEAD'), 'w') do |f|
      f.write("ref: refs/heads/#{new_head}")
    end

    $logger.info "Update head in project #{project_name} to <#{new_head}>."
    true
  end

  def gc
    $logger.info "Running git gc for <#{full_path}>."
    unless File.exists?(full_path)
      $logger.error "gc failed: destination path <#{full_path}> does not exist."
      return false
    end
    cmd = %W(git --git-dir=#{full_path} gc --no-prune)
    system(*cmd)
  end
end