# frozen_string_literal: true

#
# Copyright (C) 2012 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#

# @API Modules
#
# Modules are collections of learning materials useful for organizing courses
# and optionally providing a linear flow through them. Module items can be
# accessed linearly or sequentially depending on module configuration. Items
# can be unlocked by various criteria such as reading a page or achieving a
# minimum score on a quiz. Modules themselves can be unlocked by the completion
# of other Modules.
#
# @model Module
#     {
#       "id": "Module",
#       "description": "",
#       "properties": {
#         "id": {
#           "description": "the unique identifier for the module",
#           "example": 123,
#           "type": "integer"
#         },
#         "workflow_state": {
#           "description": "the state of the module: 'active', 'deleted'",
#           "example": "active",
#           "type": "string",
#           "allowableValues": {
#             "values": [
#               "active",
#               "deleted"
#             ]
#           }
#         },
#         "position": {
#           "description": "the position of this module in the course (1-based)",
#           "example": 2,
#           "type": "integer"
#         },
#         "name": {
#           "description": "the name of this module",
#           "example": "Imaginary Numbers and You",
#           "type": "string"
#         },
#         "unlock_at": {
#           "description": "(Optional) the date this module will unlock",
#           "example": "2012-12-31T06:00:00-06:00",
#           "type": "datetime"
#         },
#         "require_sequential_progress": {
#           "description": "Whether module items must be unlocked in order",
#           "example": true,
#           "type": "boolean"
#         },
#         "requirement_type": {
#           "description": "Whether module requires all required items or one required item to be considered complete (one of 'all' or 'one')",
#           "example": "all",
#           "type": "string"
#         },
#         "prerequisite_module_ids": {
#           "description": "IDs of Modules that must be completed before this one is unlocked",
#           "example": [121, 122],
#           "type": "array",
#           "items": {"type": "integer"}
#         },
#         "items_count": {
#           "description": "The number of items in the module",
#           "example": 10,
#           "type": "integer"
#         },
#         "items_url": {
#           "description": "The API URL to retrive this module's items",
#           "example": "https://canvas.example.com/api/v1/modules/123/items",
#           "type": "string"
#         },
#         "items": {
#           "description": "The contents of this module, as an array of Module Items. (Present only if requested via include[]=items AND the module is not deemed too large by Canvas.)",
#           "type": "array",
#           "items": { "$ref": "ModuleItem" }
#         },
#         "state": {
#           "description": "The state of this Module for the calling user one of 'locked', 'unlocked', 'started', 'completed' (Optional; present only if the caller is a student or if the optional parameter 'student_id' is included)",
#           "example": "started",
#           "type": "string",
#           "allowableValues": {
#             "values": [
#               "locked",
#               "unlocked",
#               "started",
#               "completed"
#             ]
#           }
#         },
#         "completed_at": {
#           "description": "the date the calling user completed the module (Optional; present only if the caller is a student or if the optional parameter 'student_id' is included)",
#           "type": "datetime"
#         },
#         "publish_final_grade": {
#           "description": "if the student's final grade for the course should be published to the SIS upon completion of this module",
#           "type": "boolean"
#         },
#         "published": {
#           "description": "(Optional) Whether this module is published. This field is present only if the caller has permission to view unpublished modules.",
#           "type": "boolean",
#           "example": true
#         }
#       }
#     }
#
class ContextModulesApiController < ApplicationController
  before_action :require_context
  before_action :find_student, only: [:index, :show]
  include Api::V1::ContextModule

  # @API List modules
  #
  # A paginated list of the modules in a course
  #
  # @argument include[] [String, "items"|"content_details"]
  #    - "items": Return module items inline if possible.
  #      This parameter suggests that Canvas return module items directly
  #      in the Module object JSON, to avoid having to make separate API
  #      requests for each module when enumerating modules and items. Canvas
  #      is free to omit 'items' for any particular module if it deems them
  #      too numerous to return inline. Callers must be prepared to use the
  #      {api:ContextModuleItemsApiController#index List Module Items API}
  #      if items are not returned.
  #    - "content_details": Requires 'items'. Returns additional
  #      details with module items specific to their associated content items.
  #      Includes standard lock information for each item.
  #
  # @argument search_term [String]
  #   The partial name of the modules (and module items, if 'items' is
  #   specified with include[]) to match and return.
  #
  # @argument student_id
  #   Returns module completion information for the student with this id.
  #
  # @example_request
  #     curl -H 'Authorization: Bearer <token>' \
  #          https://<canvas>/api/v1/courses/222/modules
  #
  # @returns [Module]
  def index
    if authorized_action(@context, @current_user, :read)
      log_api_asset_access(["modules", @context], "modules", "other")
      route = polymorphic_url([:api_v1, @context, :context_modules])
      scope = @context.modules_visible_to(@student || @current_user)

      includes = Array(params[:include])
      scope = ContextModule.search_by_attribute(scope, :name, params[:search_term]) unless includes.include?("items")
      modules = Api.paginate(scope, self, route)

      ActiveRecord::Associations.preload(modules, content_tags: :content) if includes.include?("items")

      modules_and_progressions = if @student
                                   modules.map { |m| [m, m.evaluate_for(@student)] }
                                 else
                                   modules.map { |m| [m, nil] }
                                 end
      opts = {}
      if includes.include?("items") && params[:search_term].present?
        SearchTermHelper.validate_search_term(params[:search_term])
        opts[:search_term] = params[:search_term]
      end

      if includes.include?("items") && @context.user_has_been_observer?(@student || @current_user)
        opts[:observed_student_ids] = ObserverEnrollment.observed_student_ids(context, @student || @current_user)
      end

      opts[:can_view_published] = @context.grants_right?(@student || @current_user, session, :read_as_admin)
      opts[:can_have_estimated_time] = @context.horizon_course?
      render json: modules_and_progressions.filter_map { |mod, prog| module_json(mod, @student || @current_user, session, prog, includes, opts) }
    end
  end

  # @API Show module
  #
  # Get information about a single module
  #
  # @argument include[] [String, "items"|"content_details"]
  #    - "items": Return module items inline if possible.
  #      This parameter suggests that Canvas return module items directly
  #      in the Module object JSON, to avoid having to make separate API
  #      requests for each module when enumerating modules and items. Canvas
  #      is free to omit 'items' for any particular module if it deems them
  #      too numerous to return inline. Callers must be prepared to use the
  #      {api:ContextModuleItemsApiController#index List Module Items API}
  #      if items are not returned.
  #    - "content_details": Requires 'items'. Returns additional
  #      details with module items specific to their associated content items.
  #      Includes standard lock information for each item.
  #
  # @argument student_id
  #   Returns module completion information for the student with this id.
  #
  # @example_request
  #     curl -H 'Authorization: Bearer <token>' \
  #          https://<canvas>/api/v1/courses/222/modules/123
  #
  # @returns Module
  def show
    if authorized_action(@context, @current_user, :read)
      mod = @context.modules_visible_to(@student || @current_user).find(params[:id])
      includes = Array(params[:include])
      ActiveRecord::Associations.preload(mod, content_tags: :content) if includes.include?("items")
      prog = @student ? mod.evaluate_for(@student) : nil

      opts = { can_view_published: @context.grants_right?(@current_user, session, :read_as_admin) }
      opts[:can_have_estimated_time] = @context.horizon_course?
      render json: module_json(mod, @student || @current_user, session, prog, includes, opts)
    end
  end

  def duplicate
    if authorized_action(@context, @current_user, :manage_course_content_add)
      old_module = @context.modules_visible_to(@current_user).find(params[:module_id])
      return render json: { error: "unable to find module to duplicate" }, status: :bad_request unless old_module
      return render json: { error: "cannot duplicate this module" }, status: :bad_request unless old_module.can_be_duplicated?

      new_module = old_module.duplicate({ user: @current_user })
      new_module.save!
      new_module.insert_at(old_module.position + 1)
      if new_module
        result_json = new_module.as_json(include: :content_tags, methods: :workflow_state)
        attachment_tags = new_module.content_tags.select do |content_tag|
          content_tag.content_type == "Attachment"
        end
        result_json["ENV_UPDATE"] = attachment_tags.map do |attachment_tag|
          { id: attachment_tag.id.to_s,
            content_id: attachment_tag.content_id,
            content_details: content_details(attachment_tag, @current_user, for_admin: true) }
        end
        render json: result_json
      else
        render json: { error: "cannot save new module" }, status: :bad_request
      end
    end
  end

  # @note API Update multiple modules
  #
  # Update multiple modules in an account.
  #
  # @argument module_ids[] [Required, String]
  #   List of ids of modules to update.
  #
  # @argument event [Required, String]
  #   The action to take on each module. Must be 'delete'.
  #
  # @argument async [Boolean]
  # . If true, the request will be processed asynchronously and a Progress will be returned.
  #
  # @response_field completed A list of IDs for modules that were updated.
  #
  # @example_request
  #     curl https://<canvas>/api/v1/courses/<course_id>/modules \
  #       -X PUT \
  #       -H 'Authorization: Bearer <token>' \
  #       -d 'event=delete' \
  #       -d 'module_ids[]=1' \
  #       -d 'module_ids[]=2'
  #
  # @example_response
  #    {
  #      "completed": [1, 2],
  #      "progress": null,
  #    }
  def batch_update
    if authorized_action(@context, @current_user, :manage_course_content_edit)
      event = params[:event]
      return render(json: { message: "need to specify event" }, status: :bad_request) unless event.present?
      return render(json: { message: "invalid event" }, status: :bad_request) unless %w[publish unpublish delete].include? event
      return render(json: { message: "must specify module_ids[]" }, status: :bad_request) unless params[:module_ids].present?

      module_ids = Api.map_non_sis_ids(Array(params[:module_ids]))
      modules = @context.context_modules.not_deleted.where(id: module_ids)
      return render(json: { message: "no modules found" }, status: :not_found) if modules.empty?

      batch_update_params = {
        event:,
        module_ids: modules.pluck(:id),
        skip_content_tags: value_to_boolean(params[:skip_content_tags])
      }
      if value_to_boolean(params[:async])
        progress = Progress.create!(context: @context, tag: "context_module_batch_update", user: @current_user)
        progress.process_job(
          @context,
          :batch_update_context_modules,
          { run_at: Time.zone.now, priority: Delayed::HIGH_PRIORITY },
          **batch_update_params
        )
      else
        completed_ids = @context.batch_update_context_modules(**batch_update_params)
      end
      render json: { completed: completed_ids, progress: }
    end
  end

  # @API Create a module
  #
  # Create and return a new module
  #
  # @argument module[name] [Required, String]
  #   The name of the module
  #
  # @argument module[unlock_at] [DateTime]
  #   The date the module will unlock
  #
  # @argument module[position] [Integer]
  #   The position of this module in the course (1-based)
  #
  # @argument module[require_sequential_progress] [Boolean]
  #   Whether module items must be unlocked in order
  #
  # @argument module[prerequisite_module_ids][] [String]
  #   IDs of Modules that must be completed before this one is unlocked.
  #   Prerequisite modules must precede this module (i.e. have a lower position
  #   value), otherwise they will be ignored
  #
  # @argument module[publish_final_grade] [Boolean]
  #   Whether to publish the student's final grade for the course upon
  #   completion of this module.
  #
  # @example_request
  #
  #     curl https://<canvas>/api/v1/courses/<course_id>/modules \
  #       -X POST \
  #       -H 'Authorization: Bearer <token>' \
  #       -d 'module[name]=module' \
  #       -d 'module[position]=2' \
  #       -d 'module[prerequisite_module_ids][]=121' \
  #       -d 'module[prerequisite_module_ids][]=122'
  #
  # @returns Module
  def create
    if authorized_action(@context.context_modules.temp_record, @current_user, :create)
      return render json: { message: "missing module parameter" }, status: :bad_request unless params[:module]
      return render json: { message: "missing module name" }, status: :bad_request unless params[:module][:name].present?

      module_parameters = params.require(:module).permit(:name, :unlock_at, :require_sequential_progress, :publish_final_grade)

      @module = @context.context_modules.build(module_parameters)

      if (ids = params[:module][:prerequisite_module_ids])
        @module.prerequisites = ids.map { |id| "module_#{id}" }.join(",")
      end
      @module.workflow_state = "unpublished"

      if @module.save && set_position
        render json: module_json(@module, @current_user, session, nil)
      else
        render json: @module.errors, status: :bad_request
      end
    end
  end

  # @API Update a module
  #
  # Update and return an existing module
  #
  # @argument module[name] [String]
  #   The name of the module
  #
  # @argument module[unlock_at] [DateTime]
  #   The date the module will unlock
  #
  # @argument module[position] [Integer]
  #   The position of the module in the course (1-based)
  #
  # @argument module[require_sequential_progress] [Boolean]
  #   Whether module items must be unlocked in order
  #
  # @argument module[prerequisite_module_ids][] [String]
  #   IDs of Modules that must be completed before this one is unlocked
  #   Prerequisite modules must precede this module (i.e. have a lower position
  #   value), otherwise they will be ignored
  #
  # @argument module[publish_final_grade] [Boolean]
  #   Whether to publish the student's final grade for the course upon
  #   completion of this module.
  #
  # @argument module[published] [Boolean]
  #   Whether the module is published and visible to students
  #
  # @example_request
  #
  #     curl https://<canvas>/api/v1/courses/<course_id>/modules/<module_id> \
  #       -X PUT \
  #       -H 'Authorization: Bearer <token>' \
  #       -d 'module[name]=module' \
  #       -d 'module[position]=2' \
  #       -d 'module[prerequisite_module_ids][]=121' \
  #       -d 'module[prerequisite_module_ids][]=122'
  #
  # @returns Module
  def update
    @module = @context.context_modules.not_deleted.find(params[:id])
    if authorized_action(@module, @current_user, :update)
      return render json: { message: "missing module parameter" }, status: :bad_request unless params[:module]

      module_parameters = params.require(:module).permit(:name, :unlock_at, :require_sequential_progress, :publish_final_grade)

      if (ids = params[:module][:prerequisite_module_ids])
        module_parameters[:prerequisites] = if ids.blank?
                                              []
                                            else
                                              ids.map { |id| "module_#{id}" }.join(",")
                                            end
      end

      if params[:module].key?(:published)
        if value_to_boolean(params[:module][:published])
          @module.publish
          unless value_to_boolean(params[:module][:skip_content_tags])
            @module.publish_items!
            publish_warning = @module.content_tags.any?(&:unpublished?)
          end
        else
          @module.unpublish
          unless value_to_boolean(params[:module][:skip_content_tags])
            @module.unpublish_items!
          end
        end
      end
      relock_warning = @module.relock_warning?

      if @module.update(module_parameters) && set_position
        json = module_json(@module, @current_user, session, nil)
        json["relock_warning"] = true if relock_warning || @module.relock_warning?
        json["publish_warning"] = publish_warning.present?
        render json:
      else
        render json: @module.errors, status: :bad_request
      end
    end
  end

  # @API Delete module
  #
  # Delete a module
  #
  # @example_request
  #
  #     curl https://<canvas>/api/v1/courses/<course_id>/modules/<module_id> \
  #       -X Delete \
  #       -H 'Authorization: Bearer <token>'
  #
  # @returns Module
  def destroy
    @module = @context.context_modules.not_deleted.find(params[:id])
    if authorized_action(@module, @current_user, :delete)
      @module.destroy
      render json: module_json(@module, @current_user, session, nil)
    end
  end

  # @API Re-lock module progressions
  #
  # Resets module progressions to their default locked state and
  # recalculates them based on the current requirements.
  #
  # Adding progression requirements to an active course will not lock students
  # out of modules they have already unlocked unless this action is called.
  #
  # @example_request
  #
  #     curl https://<canvas>/api/v1/courses/<course_id>/modules/<module_id>/relock \
  #       -X PUT \
  #       -H 'Authorization: Bearer <token>'
  #
  # @returns Module
  def relock
    @module = @context.context_modules.not_deleted.find(params[:id])
    if authorized_action(@module, @current_user, :update)
      @module.relock_progressions
      render json: module_json(@module, @current_user, session, nil)
    end
  end

  def set_position
    return true unless @module && params[:module][:position]

    if @module.insert_at(params[:module][:position].to_i)
      # see ContextModulesController#reorder
      @context.touch
      @context.context_modules.not_deleted.each(&:save_without_touching_context)
      @context.touch

      @module.reload
      true
    else
      @module.errors.add(:position, t(:invalid_position, "Invalid position"))
      false
    end
  end

  def find_student
    if params[:student_id]
      student_enrollments = @context.student_enrollments.for_user(params[:student_id])
      return render_unauthorized_action unless student_enrollments.any? { |e| e.grants_right?(@current_user, session, :read_grades) }

      @student = student_enrollments.first.user
    elsif @context.grants_right?(@current_user, session, :participate_as_student)
      @student = @current_user
    else
      true
    end
  end
  protected :find_student
end
