require 'fileutils'

module Shipit
  class Deploy < Task
    CONFIRMATIONS_REQUIRED = 5

    state_machine :status do
      after_transition to: :success, do: :schedule_continuous_delivery
      after_transition to: :success, do: :schedule_merges
      after_transition to: :success, do: :update_undeployed_commits_count
      after_transition to: :aborted, do: :trigger_revert_if_required
      after_transition any => any, do: :update_release_status
      after_transition any => any, do: :update_commit_deployments
      after_transition any => any, do: :update_last_deploy_time
    end

    has_many :commit_deployments, dependent: :destroy, inverse_of: :task, foreign_key: :task_id do
      GITHUB_STATUSES = {
        'pending' => 'pending',
        'failed' => 'failure',
        'success' => 'success',
        'error' => 'error',
        'aborted' => 'error',
      }.freeze

      def append_status(task_status)
        if github_status = GITHUB_STATUSES[task_status]
          each do |deployment|
            deployment.statuses.create!(status: github_status)
          end
        end
      end
    end

    before_create :denormalize_commit_stats
    after_create :create_commit_deployments
    after_create :update_release_status
    after_commit :broadcast_update
    after_commit :update_latest_deployed_ref, on: :update

    delegate :broadcast_update, :filter_deploy_envs, to: :stack

    def self.newer_than(deploy)
      return all unless deploy
      where('id > ?', deploy.try(:id) || deploy)
    end

    def self.older_than(deploy)
      return all unless deploy
      where('id < ?', deploy.try(:id) || deploy)
    end

    def self.since(deploy)
      return all unless deploy
      where('id >= ?', deploy.try(:id) || deploy)
    end

    def self.until(deploy)
      return all unless deploy
      where('id <= ?', deploy.try(:id) || deploy)
    end

    def build_rollback(user = nil, env: nil, force: false)
      Rollback.new(
        user_id: user&.id,
        stack_id: stack_id,
        parent_id: id,
        since_commit: stack.last_deployed_commit,
        until_commit: until_commit,
        env: env&.to_h || {},
        allow_concurrency: force,
        ignored_safeties: force,
      )
    end

    # Rolls the stack back to this deploy
    def trigger_rollback(user = AnonymousUser.new, env: nil, force: false)
      rollback = build_rollback(user, env: env, force: force)
      rollback.save!
      rollback.enqueue

      lock_reason = "A rollback for #{rollback.since_commit.sha} has been triggered. " \
        "Please make sure the reason for the rollback has been addressed before deploying again."
      stack.update!(lock_reason: lock_reason, lock_author_id: user.id)

      rollback
    end

    # Rolls the stack back to the most recent **previous** successful deploy
    def trigger_revert(force: false)
      previous_successful_commit = commit_to_rollback_to

      rollback = Rollback.create!(
        user_id: user_id,
        stack_id: stack_id,
        parent_id: id,
        since_commit: until_commit,
        until_commit: previous_successful_commit,
        allow_concurrency: force,
      )

      rollback.enqueue
      lock_reason = "A rollback for #{until_commit.sha} has been triggered. " \
        "Please make sure the reason for the rollback has been addressed before deploying again."
      stack.update!(lock_reason: lock_reason, lock_author_id: user_id)
      rollback
    end

    def title
      I18n.t("#{self.class.name.demodulize.underscore.pluralize}.description", sha: until_commit.short_sha)
    end

    def rollback?
      false
    end

    def commit_range
      [since_commit, until_commit]
    end

    delegate :supports_rollback?, to: :stack

    def rollbackable?
      success? && supports_rollback? && !currently_deployed?
    end

    def currently_deployed?
      until_commit_id == stack.last_deployed_commit.id
    end

    def commits
      return Commit.none unless stack

      @commits ||= stack.commits.reachable.newer_than(since_commit_id).until(until_commit_id).order(id: :desc)
    end

    def commits_since
      return Commit.none unless stack

      @commits_since ||= stack.commits.reachable.newer_than(until_commit_id).order(id: :desc)
    end

    def since_commit_id
      super || default_since_commit_id
    end

    def variables
      stack.deploy_variables
    end

    def reject!
      return if failed? || aborted?
      transaction do
        flap! unless flapping?
        update!(confirmations: [confirmations - 1, -1].min)
        failure! if confirmed?
      end
    end

    def accept!
      return if success?
      transaction do
        flap! unless flapping?
        update!(confirmations: [confirmations + 1, 1].max)
        complete! if confirmed?
      end
    end

    def confirmed?
      confirmations.abs >= CONFIRMATIONS_REQUIRED
    end

    delegate :last_release_status, to: :until_commit
    def append_release_status(state, description, user: self.user)
      status = until_commit.create_release_status!(
        state,
        user: user.presence,
        target_url: permalink,
        description: description,
      )
      status
    end

    def permalink
      Shipit::Engine.routes.url_helpers.stack_deploy_url(stack, self)
    end

    def report_complete!
      if stack.release_status? && stack.release_status_delay.positive?
        enter_validation!
      else
        super
      end
    end

    def report_healthy!(user: self.user, description: "@#{user.login} signaled this release as healthy.")
      transaction do
        complete! if can_complete?
        append_release_status(
          'success',
          description,
          user: user,
        )
      end
    end

    def report_faulty!(user: self.user, description: "@#{user.login} signaled this release as faulty.")
      transaction do
        mark_faulty! if can_mark_faulty?
        append_release_status(
          'failure',
          description,
          user: user,
        )
      end
    end

    private

    def create_commit_deployments
      commits.each do |commit|
        commit_deployments.create!(commit: commit)
      end
    end

    def update_release_status
      return unless stack.release_status?

      case status
      when 'pending'
        append_release_status('pending', "A deploy was triggered on #{stack.environment}")
      when 'failed', 'error', 'timedout'
        append_release_status('error', "The deploy on #{stack.environment} did not succeed (#{status})")
      when 'aborted', 'aborting'
        append_release_status('failure', "The deploy on #{stack.environment} was canceled")
      when 'validating'
        if stack.release_status_delay.positive?
          append_release_status('pending', "The deploy on #{stack.environment} succeeded")
          MarkDeployHealthyJob.set(wait: stack.release_status_delay).perform_later(self)
        end
      when 'success'
        if stack.release_status_delay.zero?
          append_release_status('success', "The deploy on #{stack.environment} succeeded")
        end
      end
    end

    def update_commit_deployments
      commit_deployments.append_status(status)
    end

    def trigger_revert_if_required
      return unless rollback_once_aborted?
      return unless supports_rollback?
      trigger_revert
    end

    def default_since_commit_id
      return unless stack
      @default_since_commit_id ||= stack.last_completed_deploy&.until_commit_id
    end

    def denormalize_commit_stats
      self.additions = commits.map(&:additions).compact.sum
      self.deletions = commits.map(&:deletions).compact.sum
    end

    def schedule_merges
      stack.schedule_merges
    end

    def schedule_continuous_delivery
      return unless stack.continuous_deployment?
      ContinuousDeliveryJob.perform_later(stack)
    end

    def update_undeployed_commits_count
      stack.update_undeployed_commits_count(until_commit)
    end

    def update_last_deploy_time
      stack.update(last_deployed_at: ended_at)
    end

    def update_latest_deployed_ref
      return unless previous_changes.include?(:status)
      stack.update_latest_deployed_ref if previous_changes[:status].last == 'success'
    end
  end
end
