# frozen_string_literal: true

class ExercisesController < ApplicationController
  include CommonBehavior
  include RedirectBehavior
  include Lti
  include SubmissionParameters
  include TimeHelper

  before_action :handle_file_uploads, only: %i[create update]
  before_action :set_execution_environments, only: %i[index create edit new update]
  before_action :set_exercise_and_authorize,
    only: MEMBER_ACTIONS + %i[clone implement working_times intervention search run statistics submit reload feedback
                              requests_for_comments study_group_dashboard export_external_check export_external_confirm
                              external_user_statistics]
  before_action :collect_set_and_unset_exercise_tags, only: MEMBER_ACTIONS
  before_action :set_external_user_and_authorize, only: [:external_user_statistics]
  before_action :set_file_types, only: %i[create edit new update]
  before_action :set_available_tips, only: %i[implement show new edit]

  skip_before_action :verify_authenticity_token, only: %i[import_task import_uuid_check]
  skip_after_action :verify_authorized, only: %i[import_task import_uuid_check]
  skip_after_action :verify_policy_scoped, only: %i[import_task import_uuid_check], raise: false

  rescue_from Pundit::NotAuthorizedError, with: :not_authorized_for_exercise

  def authorize!
    authorize(@exercise || @exercises)
  end

  private :authorize!

  def max_intervention_count_per_day
    3
  end

  def max_intervention_count_per_exercise
    1
  end

  def batch_update
    @exercises = Exercise.all
    authorize!
    @exercises = params[:exercises].values.map do |exercise_params|
      exercise = Exercise.find(exercise_params.delete(:id))
      exercise.update(exercise_params)
      exercise
    end
    render(json: {exercises: @exercises})
  end

  def clone
    exercise = @exercise.duplicate(public: false, token: nil, user: current_user)
    exercise.send(:generate_token)
    if exercise.save
      redirect_to(exercise_path(exercise), notice: t('shared.object_cloned', model: Exercise.model_name.human))
    else
      flash[:danger] = t('shared.message_failure')
      redirect_to(@exercise)
    end
  end

  def collect_paths(files)
    unique_paths = files.map(&:path).compact_blank.uniq
    subpaths = unique_paths.map do |path|
      Array.new((path.split('/').length + 1)) do |n|
        path.split('/').shift(n).join('/')
      end
    end
    subpaths.flatten.uniq
  end

  private :collect_paths

  def index
    @search = policy_scope(Exercise).ransack(params[:q])
    @exercises = @search.result.includes(:execution_environment, :user, :files, :exercise_tags).order(:title).paginate(page: params[:page], per_page: per_page_param)
    authorize!
  end

  def show
    # Show exercise details for teachers and admins
  end

  def new
    @exercise = Exercise.new
    authorize!
    collect_set_and_unset_exercise_tags
  end

  def feedback
    authorize!
    @feedbacks = @exercise
      .user_exercise_feedbacks
      .includes(:exercise, user: [:programming_groups])
      .paginate(page: params[:page], per_page: per_page_param)
    @submissions = @feedbacks.map do |feedback|
      feedback.exercise.final_submission(feedback.user.programming_groups.select {|pg| pg.exercise = @exercise }.presence || feedback.user)
    end
  end

  def export_external_check
    codeharbor_check = ExerciseService::CheckExternal.call(uuid: @exercise.uuid,
      codeharbor_link: current_user.codeharbor_link)
    render json: {
      message: codeharbor_check[:message],
      actions: render_to_string(
        partial: 'export_actions',
        locals: {
          exercise: @exercise,
          uuid_found: codeharbor_check[:uuid_found],
          update_right: codeharbor_check[:update_right],
          error: codeharbor_check[:error],
          exported: false,
        }
      ),
    }, status: :ok
  end

  def export_external_confirm
    authorize!
    @exercise.uuid = SecureRandom.uuid if @exercise.uuid.nil?

    error = ExerciseService::PushExternal.call(
      zip: ProformaService::ExportTask.call(exercise: @exercise),
      codeharbor_link: current_user.codeharbor_link
    )
    if error.nil?
      render json: {
        status: 'success',
        message: t('exercises.export_codeharbor.successfully_exported', id: @exercise.id, title: @exercise.title),
        actions: render_to_string(partial: 'export_actions',
          locals: {exercise: @exercise, exported: true, error:}),
      }
      @exercise.save
    else
      render json: {
        status: 'fail',
        message: t('exercises.export_codeharbor.export_failed', id: @exercise.id, title: @exercise.title, error:),
        actions: render_to_string(partial: 'export_actions',
          locals: {exercise: @exercise, exported: true, error:}),
      }
    end
  end

  def import_uuid_check
    user = user_from_api_key
    return render json: {}, status: :unauthorized if user.nil?

    uuid = params[:uuid]
    exercise = Exercise.find_by(uuid:)

    return render json: {uuid_found: false} if exercise.nil?
    return render json: {uuid_found: true, update_right: false} unless ExercisePolicy.new(user, exercise).update?

    render json: {uuid_found: true, update_right: true}
  end

  def import_task
    tempfile = Tempfile.new('codeharbor_import.zip')
    tempfile.write request.body.read.force_encoding('UTF-8')
    tempfile.rewind

    user = user_from_api_key
    return render json: {}, status: :unauthorized if user.nil?

    ActiveRecord::Base.transaction do
      exercise = ::ProformaService::Import.call(zip: tempfile, user:)
      exercise.save!
      render json: {}, status: :created
    end
  rescue ProformaXML::ExerciseNotOwned
    render json: {}, status: :unauthorized
  rescue ProformaXML::ProformaError
    render json: t('exercises.import_codeharbor.import_errors.invalid'), status: :bad_request
  rescue StandardError => e
    Sentry.capture_exception(e)
    render json: t('exercises.import_codeharbor.import_errors.internal_error'), status: :internal_server_error
  end

  def user_from_api_key
    authorization_header = request.headers['Authorization']
    api_key = authorization_header&.split(' ')&.second
    user_by_codeharbor_token(api_key)
  end

  private :user_from_api_key

  def user_by_codeharbor_token(api_key)
    link = CodeharborLink.find_by(api_key:)
    link&.user
  end

  private :user_by_codeharbor_token

  def exercise_params
    @exercise_params ||= if params[:exercise].present?
                           params[:exercise].permit(
                             :description,
                             :execution_environment_id,
                             :file_id,
                             :instructions,
                             :submission_deadline,
                             :late_submission_deadline,
                             :public,
                             :unpublished,
                             :hide_file_tree,
                             :allow_file_creation,
                             :allow_auto_completion,
                             :title,
                             :expected_difficulty,
                             :tips,
                             files_attributes: file_attributes,
                             tag_ids: []
                           ).merge(
                             user: current_user
                           )
                         end
  end

  private :exercise_params

  def exercise_params_with_tags
    myparam = exercise_params.presence || {}
    checked_exercise_tags = @exercise_tags.select {|et| myparam[:tag_ids]&.include? et.tag.id.to_s }
    removed_exercise_tags = @exercise_tags.reject {|et| myparam[:tag_ids]&.include? et.tag.id.to_s }

    checked_exercise_tags.each do |et|
      et.factor = params[:tag_factors][et.tag_id.to_s][:factor]
      et.exercise = @exercise
    end

    myparam[:exercise_tags] = checked_exercise_tags
    myparam.delete :tag_ids
    myparam.delete :tips
    removed_exercise_tags.map(&:destroy)
    myparam
  end
  private :exercise_params_with_tags

  def handle_file_uploads
    if exercise_params
      exercise_params[:files_attributes].try(:each) do |_index, file_attributes|
        if file_attributes[:content].respond_to?(:read)
          if FileType.find_by(id: file_attributes[:file_type_id]).try(:binary?)
            file_attributes[:native_file] = file_attributes[:content]
            file_attributes[:content] = nil
          else
            file_attributes[:content] = file_attributes[:content].read.detect_encoding!.encode.delete("\x00")
          end
        end
      end
    end
  end

  private :handle_file_uploads

  def handle_exercise_tips(tips_params)
    return unless tips_params

    begin
      exercise_tips = JSON.parse(tips_params)
      # Order is important to ensure no foreign key restraints are violated during delete
      previous_exercise_tips = ExerciseTip.where(exercise: @exercise).select(:id).order(rank: :desc).ids
      remaining_exercise_tips = update_exercise_tips exercise_tips, nil, 1
      # Destroy initializes each object and then calls a *single* SQL DELETE
      ExerciseTip.destroy(previous_exercise_tips - remaining_exercise_tips)
    rescue JSON::ParserError => e
      flash[:danger] = "JSON error: #{e.message}"
      redirect_to(edit_exercise_path(@exercise))
    end
  end

  private :handle_exercise_tips

  def update_exercise_tips(exercise_tips, parent_exercise_tip_id, rank)
    result = []
    exercise_tips.each do |exercise_tip|
      exercise_tip.symbolize_keys!
      current_exercise_tip = ExerciseTip.find_or_initialize_by(id: exercise_tip[:id],
        exercise: @exercise,
        tip_id: exercise_tip[:tip_id])
      current_exercise_tip.parent_exercise_tip_id = parent_exercise_tip_id
      current_exercise_tip.rank = rank
      rank += 1
      unless current_exercise_tip.save
        flash[:danger] = current_exercise_tip.errors.full_messages.join('. ')
        redirect_to(edit_exercise_path(@exercise)) and break
      end

      children = update_exercise_tips exercise_tip[:children], current_exercise_tip.id, rank
      rank += children.length

      result << current_exercise_tip.id
      result += children
    end
    result
  end

  private :update_exercise_tips

  def implement
    if session[:pg_id] && current_contributor.exercise != @exercise
      # we are acting on behalf of a programming group
      if current_user.admin?
        session.delete(:pg_id)
        @current_contributor = current_user
      else
        return redirect_back(
          fallback_location: implement_exercise_path(current_contributor.exercise),
          alert: t('exercises.implement.existing_programming_group', exercise: current_contributor.exercise.title)
        )
      end
    elsif session[:pg_id].blank? && (pg = current_user.programming_groups.find_by(exercise: @exercise)) && pg.submissions.where(study_group_id: current_user.current_study_group_id).any?
      # we are just acting on behalf of a single user who has already worked on this exercise as part of a programming group **in the context of the current study group**
      session[:pg_id] = pg.id
      @current_contributor = pg
    elsif PairProgramming23Study.participate?(current_user, @exercise) && current_user.submissions.where(study_group_id: current_user.current_study_group_id, exercise: @exercise).any?
      Event.create(category: 'pp_work_alone', user: current_user, exercise: @exercise, data: nil, file_id: nil)
    end

    user_solved_exercise = @exercise.solved_by?(current_contributor)
    count_interventions_today = UserExerciseIntervention.where(user: current_user).where('created_at >= ?',
      Time.zone.now.beginning_of_day).count
    user_got_intervention_in_exercise = UserExerciseIntervention.where(user: current_user,
      exercise: @exercise).size >= max_intervention_count_per_exercise
    (user_got_enough_interventions = count_interventions_today >= max_intervention_count_per_day) || user_got_intervention_in_exercise

    if @embed_options[:disable_interventions]
      @show_rfc_interventions = false
      @show_break_interventions = false
      @show_tips_interventions = false
    else
      show_intervention = (!user_solved_exercise && !user_got_enough_interventions).to_s
      if @tips.present? && Java21Study.show_tips_intervention?(current_user, @exercise)
        @show_tips_interventions = show_intervention
        @show_break_interventions = false
        @show_rfc_interventions = false
      elsif Java21Study.show_break_intervention?(current_user, @exercise)
        @show_tips_interventions = false
        @show_break_interventions = show_intervention
        @show_rfc_interventions = false
      else
        @show_tips_interventions = false
        @show_break_interventions = false
        @show_rfc_interventions = show_intervention
      end
    end

    @embed_options[:disable_score] = true unless @exercise.teacher_defined_assessment?

    @hide_rfc_button = @embed_options[:disable_rfc]

    @submission = current_contributor.submissions.order(created_at: :desc).find_by(exercise: @exercise)
    @files = (@submission ? @submission.collect_files : @exercise.files).select(&:visible).sort_by(&:filepath)
    @paths = collect_paths(@files)
  end

  def set_available_tips
    # Order of elements is important and will be kept
    available_tips = ExerciseTip.where(exercise: @exercise).order(rank: :asc).includes(:tip)

    # Transform result set in a hash and prepare (temporary) children array.
    # The children array will contain the sorted list of nested tips,
    # shown for learners in the output sidebar with cards.
    # Hash - Key: exercise_tip.id, value: exercise_tip Object loaded from database
    nested_tips = available_tips.each_with_object({}) do |exercise_tip, hash|
      exercise_tip.children = []
      hash[exercise_tip.id] = exercise_tip
    end

    available_tips.each do |tip|
      # A tip without a parent cannot be a children
      next if tip.parent_exercise_tip_id.blank?

      # Link tips if they are related
      nested_tips[tip.parent_exercise_tip_id].children << tip
    end

    # Return an array with top-level tips
    @tips = nested_tips.values.select {|tip| tip.parent_exercise_tip_id.nil? }
  end

  private :set_available_tips

  def working_times
    working_time_accumulated = @exercise.accumulated_working_time_for_only(current_contributor)
    working_time_75_percentile = @exercise.get_quantiles([0.75]).first
    render(json: {working_time_75_percentile:,
                   working_time_accumulated:})
  end

  def intervention
    intervention = Intervention.find_by(name: params[:intervention_type])
    if intervention.nil?
      render(json: {success: 'false', error: "undefined intervention #{params[:intervention_type]}"})
    else
      uei = UserExerciseIntervention.new(
        user: current_contributor, exercise: @exercise, intervention:,
        accumulated_worktime_s: @exercise.accumulated_working_time_for_only(current_contributor)
      )
      uei.save
      render(json: {success: 'true'})
    end
  end

  def edit; end

  def create
    @exercise = Exercise.new(exercise_params&.except(:tips))
    authorize!
    collect_set_and_unset_exercise_tags
    tips_params = exercise_params&.dig(:tips)
    return if performed?

    create_and_respond(object: @exercise, params: exercise_params_with_tags) do
      # We first need to create the exercise before handling tips
      handle_exercise_tips tips_params
    end
  end

  def not_authorized_for_exercise(_exception)
    return render_not_authorized unless current_user
    return render_not_authorized unless %w[implement working_times intervention reload].include?(action_name)

    if current_user.admin? || current_user.teacher?
      redirect_to(@exercise, alert: t('exercises.implement.unpublished')) if @exercise.unpublished?
      redirect_to(@exercise, alert: t('exercises.implement.no_files')) unless @exercise.files.visible.exists?
      redirect_to(@exercise, alert: t('exercises.implement.no_execution_environment')) if @exercise.execution_environment.blank?
    else
      render_not_authorized
    end
  end
  private :not_authorized_for_exercise

  def set_execution_environments
    @execution_environments = ExecutionEnvironment.all.order(:name)
  end

  private :set_execution_environments

  def set_exercise_and_authorize
    @exercise = Exercise.includes(:exercise_tips, files: [:file_type]).find(params[:id])
    authorize!
  end

  private :set_exercise_and_authorize

  def set_external_user_and_authorize
    if params[:external_user_id]
      @external_user = ExternalUser.find(params[:external_user_id])
      authorize!
    end
  end

  private :set_external_user_and_authorize

  def set_file_types
    @file_types = FileType.all.order(:name)
  end

  private :set_file_types

  def collect_set_and_unset_exercise_tags
    @tags = policy_scope(Tag)
    checked_exercise_tags = @exercise.exercise_tags
    checked_tags = checked_exercise_tags.to_set(&:tag)
    unchecked_tags = Tag.all.to_set.subtract checked_tags
    @exercise_tags = checked_exercise_tags + unchecked_tags.collect do |tag|
      ExerciseTag.new(exercise: @exercise, tag:)
    end
  end

  private :collect_set_and_unset_exercise_tags

  def update
    handle_exercise_tips exercise_params&.dig(:tips)
    return if performed?

    update_and_respond(object: @exercise, params: exercise_params_with_tags)
  end

  def reload
    # Returns JSON with original file content
  end

  def statistics
    # Show general statistic page for specific exercise
    contributor_statistics = {'InternalUser' => {}, 'ExternalUser' => {}, 'ProgrammingGroup' => {}}

    query = Submission.select('contributor_id, contributor_type, MAX(score) AS maximum_score, COUNT(id) AS runs')
      .where(exercise_id: @exercise.id)
      .group('contributor_id, contributor_type')

    query = if policy(@exercise).detailed_statistics?
              query
            elsif !policy(@exercise).detailed_statistics? && current_user.study_groups.count.positive?
              query.where(study_groups: current_user.study_groups.pluck(:id), cause: 'submit')
            else
              # e.g. internal user without any study groups, show no submissions
              query.where('false')
            end

    query.each do |tuple|
      contributor_statistics[tuple['contributor_type']][tuple['contributor_id'].to_i] = tuple
    end

    render locals: {
      contributor_statistics:,
    }
  end

  def external_user_statistics
    # Render statistics page for one specific external user

    if policy(@exercise).detailed_statistics?
      submissions = Submission.where(contributor: @external_user, exercise: @exercise)
        .in_study_group_of(current_user)
        .order(:created_at)
      @show_autosaves = params[:show_autosaves] == 'true' || submissions.none? {|s| s.cause != 'autosave' }
      submissions = submissions.where.not(cause: 'autosave') unless @show_autosaves
      interventions = UserExerciseIntervention.where('user_id = ?  AND exercise_id = ?', @external_user.id,
        @exercise.id)
      @all_events = (submissions + interventions).sort_by(&:created_at)
      @deltas = @all_events.map.with_index do |item, index|
        delta = item.created_at - @all_events[index - 1].created_at if index.positive?
        delta.nil? || (delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS) ? 0 : delta
      end
      @working_times_until = []
      @all_events.each_with_index do |_, index|
        @working_times_until.push((format_time_difference(@deltas[0..index].sum) if index.positive?))
      end
    else
      final_submissions = Submission.where(contributor: @external_user,
        exercise_id: @exercise.id).in_study_group_of(current_user).final
      submissions = []
      %i[before_deadline within_grace_period after_late_deadline].each do |filter|
        relevant_submission = final_submissions.send(filter).latest
        submissions.push relevant_submission if relevant_submission.present?
      end
      @all_events = submissions
    end

    render 'exercises/external_users/statistics'
  end

  def submit
    @submission = Submission.create(submission_params)
    @submission.calculate_score(current_user)

    if @submission.users.map {|user| lti_outcome_service?(@submission.exercise, user, @submission.study_group_id) }.any?
      transmit_lti_score
    else
      redirect_after_submit
    end
  rescue Runner::Error => e
    Rails.logger.debug { "Runner error while submitting submission #{@submission.id}: #{e.message}" }
    respond_to do |format|
      format.html { redirect_to(implement_exercise_path(@submission.exercise)) }
      format.json { render(json: {danger: I18n.t('exercises.editor.depleted'), status: :container_depleted}) }
    end
  end

  def transmit_lti_score
    responses = send_scores(@submission)
    messages = {}
    failed_users = []

    responses.each do |response|
      if Lti::ERROR_STATUS.include? response[:status]
        failed_users << response[:user]
      elsif response[:score_sent] != @submission.normalized_score # the score was sent successfully, but received too late
        messages[:warning] = I18n.t('exercises.submit.too_late')
      end
    end

    if failed_users.size == responses.size # all submissions failed
      messages[:danger] = I18n.t('exercises.submit.failure')
    elsif failed_users.size.positive? # at least one submission failed
      messages[:warning] = [[messages[:warning]], I18n.t('exercises.submit.warning_not_for_all_users_submitted', user: failed_users.join(', '))].join('<br><br>')
      messages[:warning] = "#{messages[:warning]}\n\n#{I18n.t('exercises.submit.warning_not_for_all_users_submitted', user: failed_users.join(', '))}".strip
    else
      messages.each do |type, message_text|
        flash.now[type] = message_text
        flash.keep(type)
      end
      return redirect_after_submit
    end

    respond_to do |format|
      format.html { redirect_to(implement_exercise_path(@submission.exercise), **messages) }
      format.json { render(json: messages) } # We must not change the HTTP status code here, since otherwise the custom message is not displayed.
    end
  end

  private :transmit_lti_score

  def destroy
    destroy_and_respond(object: @exercise)
  end

  def study_group_dashboard
    authorize!
    @study_group_id = params[:study_group_id]
    @request_for_comments = RequestForComment
      .where(exercise: @exercise).includes(:submission)
      .where(submissions: {study_group_id: @study_group_id})
      .order(created_at: :desc)

    @graph_data = @exercise.get_working_times_for_study_group(@study_group_id)
  end
end
