# frozen_string_literal: true

#
# Copyright (C) 2011 - 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/>.
#

require "benchmark"

# @API Discussion Topics
class DiscussionTopicsApiController < ApplicationController
  include Api::V1::DiscussionTopics
  include Api::V1::User
  include SubmittableHelper
  include LocaleSelection

  before_action :require_context_and_read_access
  before_action :require_topic, except: %i[mark_all_topic_read migrate_disallow update_discussion_types]
  before_action :require_initial_post, except: %i[add_entry
                                                  mark_topic_read
                                                  mark_topic_unread
                                                  mark_all_topic_read
                                                  migrate_disallow
                                                  show
                                                  unsubscribe_topic
                                                  update_discussion_types]
  before_action only: %i[replies
                         entries
                         add_entry
                         add_reply
                         show
                         view
                         entry_list
                         subscribe_topic] do
    check_differentiated_assignments(@topic)
  end

  # @API Get a single topic
  #
  # Returns data on an individual discussion topic. See the List action for the response formatting.
  #
  # @argument include[] [String, "all_dates", "sections", "sections_user_count", "overrides"]
  #   If "all_dates" is passed, all dates associated with graded discussions'
  #   assignments will be included.
  #   if "sections" is passed, includes the course sections that are associated
  #   with the topic, if the topic is specific to certain sections of the course.
  #   If "sections_user_count" is passed, then:
  #     (a) If sections were asked for *and* the topic is specific to certain
  #         course sections, includes the number of users in each
  #         section. (as part of the section json asked for above)
  #     (b) Else, includes at the root level the total number of users in the
  #         topic's context (group or course) that the topic applies to.
  #   If "overrides" is passed, the overrides for the assignment will be included
  #
  # @example_request
  #
  #     curl https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id> \
  #         -H 'Authorization: Bearer <token>'
  def show
    return unless is_not_anonymous

    include_params = Array(params[:include])
    log_asset_access(@topic, "topics", "topics")
    render(json: discussion_topics_api_json([@topic],
                                            @context,
                                            @current_user,
                                            session,
                                            include_all_dates: include_params.include?("all_dates"),
                                            include_sections: include_params.include?("sections"),
                                            include_sections_user_count: include_params.include?("sections_user_count"),
                                            include_overrides: include_params.include?("overrides")).first)
  end

  # @API Find Last Summary
  #
  # Returns:
  # (1) last userInput (what current user had keyed in to produce the last discussion summary),
  # (2) last discussion summary generated by the current user for current discussion topic, based on userInput,
  # (3) and some usage information.
  #
  # @example_request
  #
  #     curl https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/summaries \
  #         -H 'Authorization: Bearer <token>'
  #
  # @example_response
  #
  #     {
  #       "id": 1,
  #       "userInput": "Give me a brief summary of the discussion.",
  #       "text": "This is a summary of the discussion topic.",
  #       "usage": { "currentCount": 1, "limit": 5 }
  #     }
  def find_summary
    return render_unauthorized_action unless @topic.user_can_summarize?(@current_user)

    unless !!llm_config_raw && !!llm_config_refined
      logger.error("No LLM config found for discussion topic summary")
      render(
        json: {
          error: t("Sorry, we are unable to summarize this discussion at this time. Please try again later.")
        },
        status: :unprocessable_entity
      ) and return
    end

    current_count = Canvas.redis.get(cache_key).to_i
    limit = llm_config_raw.rate_limit[:limit]
    summary = @topic.summaries.where(user: @current_user, llm_config_version: llm_config_refined.name).order(created_at: :desc).first
    parent_summary = summary&.parent

    unless summary && parent_summary
      render(json: { error: t("No summary found.") }, status: :not_found) and return
    end

    focus = generate_focus(summary.user_input)
    old_content_hash = parent_summary.dynamic_content_hash
    new_content_hash = generate_raw_dynamic_content(focus)[:hash]
    summary_obsolete = old_content_hash != new_content_hash

    render(json: { id: summary.id, text: summary.summary, userInput: summary.user_input, obsolete: summary_obsolete, usage: { currentCount: current_count, limit: } })
  end

  # @API Find or Create Summary
  #
  # Generates a summary for a discussion topic. Returns the summary text and usage information.
  #
  # @argument userInput [String]
  #   Areas or topics for the summary to focus on.
  #
  # @example_request
  #
  #     curl https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/summaries \
  #         -X POST \
  #         -H 'Authorization: Bearer <token>'
  #
  # @example_response
  #
  #     {
  #       "id": 1,
  #       "text": "This is a summary of the discussion topic.",
  #       "usage": { "currentCount": 1, "limit": 5 }
  #     }
  def find_or_create_summary
    return render_unauthorized_action unless @topic.user_can_summarize?(@current_user)

    unless !!llm_config_raw && !!llm_config_refined
      logger.error("No LLM config found for discussion topic summary")
      render(
        json: {
          error: t("Sorry, we are unable to summarize this discussion at this time. Please try again later.")
        },
        status: :unprocessable_entity
      ) and return
    end

    user_input = params[:userInput]
    focus = generate_focus(user_input)
    raw_dynamic_content, raw_dynamic_content_hash = generate_raw_dynamic_content(focus)
                                                    .values_at(:content, :hash)
    raw_summary = fetch_or_create_summary(
      llm_config: llm_config_raw,
      dynamic_content: raw_dynamic_content,
      dynamic_content_hash: raw_dynamic_content_hash,
      user_input:
    )

    refined_dynamic_content, refined_dynamic_content_hash = generate_refined_dynamic_content(focus, raw_summary)
                                                            .values_at(:content, :hash)

    refined_summary = fetch_or_create_summary(
      llm_config: llm_config_refined,
      dynamic_content: refined_dynamic_content,
      dynamic_content_hash: refined_dynamic_content_hash,
      user_input:,
      parent_summary: raw_summary,
      locale:
    )

    unless @topic.summary_enabled
      @topic.update!(summary_enabled: true)
    end

    current_count = Canvas.redis.get(cache_key).to_i
    limit = llm_config_raw.rate_limit[:limit]

    render(json: { id: refined_summary.id, text: refined_summary.summary, usage: { currentCount: current_count, limit: } })
  rescue => e
    logger.error("Error summarizing discussion topic: #{e.class} - #{e.message}")

    case e
    when InstLLM::ServiceQuotaExceededError
      render(json: { error: t("Sorry, we are currently experiencing high demand. Please try again later.") }, status: :service_unavailable)
    when InstLLM::ThrottlingError
      render(json: { error: t("Sorry, the service is currently busy. Please try again later.") }, status: :service_unavailable)
    when InstLLM::ValidationTooLongError
      render(json: { error: t("Sorry, we are unable to summarize this discussion as it is too long.") }, status: :unprocessable_entity)
    when InstLLM::ValidationError
      render(json: { error: t("Oops! There was an error validating the service request. Please try again later.") }, status: :unprocessable_entity)
    when InstLLMHelper::RateLimitExceededError
      render(json: { error: t("Sorry, you have reached the maximum number of summary generations allowed (%{limit}) for now. Please try again later.", limit: e.limit) }, status: :too_many_requests)
    else
      Canvas::Errors.capture_exception(:discussion_summary, e, :error)
      render(json: { error: t("Sorry, we are unable to summarize this discussion at this time. Please try again later.") }, status: :unprocessable_entity)
    end
  end

  # @API Disable summary
  # Deprecated, to remove after VICE-5047 gets merged
  # Disables the summary for a discussion topic.
  #
  # @example_request
  #
  #     curl -X PUT https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/disable_summary \
  #
  # @example_response
  #
  #     {
  #       "success": true
  #     }
  def disable_summary
    return render_unauthorized_action unless @topic.user_can_summarize?(@current_user)

    @topic.update!(summary_enabled: false)

    render(json: { success: true })
  end

  # @API Summary Feedback
  #
  # Persists feedback on a discussion topic summary.
  #
  # @argument _action [String] Required
  #   The action to take on the summary. Possible values are:
  #   - "seen": Marks the summary as seen. This action saves the feedback if it's not already persisted.
  #   - "like": Marks the summary as liked.
  #   - "dislike": Marks the summary as disliked.
  #   - "reset_like": Resets the like status of the summary.
  #   - "regenerate": Regenerates the summary feedback.
  #   - "disable_summary": Disables the summary feedback.
  #   Any other value will result in an error response.
  #
  # @example_request
  #
  #     curl -X POST https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/summaries/<summary_id>/feedback \
  #          -F '_action=like' \
  #          -H "Authorization: Bearer
  #
  # @example_response
  #
  #     {
  #       "liked": true,
  #       "disliked": false
  #     }
  def summary_feedback
    return render_unauthorized_action unless @topic.user_can_summarize?(@current_user)

    begin
      dts = @topic.summaries.find(params[:summary_id])
    rescue ActiveRecord::RecordNotFound
      render(json: { error: "Summary not found." }, status: :not_found) and return
    end

    feedback = dts.feedback.find_or_initialize_by(user: @current_user)
    action = params[:_action].to_sym

    case action
    when :seen
      feedback.save! unless feedback.persisted?
    when :like
      feedback.like
    when :dislike
      feedback.dislike
    when :reset_like
      feedback.reset_like
    when :disable_summary
      feedback.disable_summary
    else
      logger.warn("Invalid discussion topic summary feedback action: #{action}")
      render(json: { error: "Invalid action." }, status: :bad_request) and return
    end

    render(json: { liked: feedback.liked, disliked: feedback.disliked })
  end

  def insight
    return render_unauthorized_action unless @topic.user_can_access_insights?(@current_user)

    insight = @topic.insights.order(created_at: :desc).first
    if insight.nil?
      return render(json: { workflow_state: nil })
    end

    data = {
      workflow_state: insight.workflow_state,
    }

    if DiscussionTopicInsight::TERMINAL_WORKFLOW_STATES.include?(insight.workflow_state)
      data[:needs_processing] = insight.needs_processing?
      # The needs_processing in the root is obsolete. The reason it's still here is to maintain backward compatibility
      # with speedgrader's current behaviour. Once the latest speedgrader is deployed, we can remove this.

      data[:student_ids_needs_processing] = insight.student_ids_needs_processing
    end

    render(json: data)
  end

  def insight_generation
    return render_unauthorized_action unless @topic.user_can_access_insights?(@current_user)

    DiscussionTopicInsight.transaction do
      insight = @topic.insights.create!(
        workflow_state: "created",
        user: @current_user
      )

      # delay will fail if the job is already queued, because the job is a singleton
      insight.delay(
        priority: Delayed::HIGH_PRIORITY,
        singleton: "discussion_topic:insight_generation_for_topic:#{@topic.id}",
        n_strand: ["discussion_topic:insight_generation:#{Shard.current.database_server.region}", 1]
      ).generate
    end

    render json: {}
  rescue => e
    logger.error("Error generating insight for discussion topic: #{e.class} - #{e.message}")
    render json: { error: "Failed to generate insight for discussion topic." }, status: :unprocessable_entity
  end

  def insight_entries
    return render_unauthorized_action unless @topic.user_can_access_insights?(@current_user)

    insight = @topic.insights.order(created_at: :desc).first
    if insight.nil?
      return render(json: [])
    end

    insight_entries = insight.processed_entries.map do |insight_entry|
      {
        id: insight_entry.id,
        entry_id: insight_entry.discussion_entry.id,
        entry_content: insight_entry.discussion_entry_version.message,
        entry_updated_at: insight_entry.discussion_entry_version.updated_at,
        student_id: insight_entry.discussion_entry.user_id,
        student_name: insight_entry.user.short_name,
        # TODO: these will change
        relevance_ai_classification: insight_entry.ai_evaluation["final_label"],
        relevance_ai_evaluation_notes: insight_entry.ai_evaluation["feedback"],
        relevance_human_reviewer: insight_entry.ai_evaluation_human_reviewer_id,
        relevance_human_feedback_liked: insight_entry.ai_evaluation_human_feedback_liked,
        relevance_human_feedback_disliked: insight_entry.ai_evaluation_human_feedback_disliked,
        relevance_human_feedback_notes: insight_entry.ai_evaluation_human_feedback_notes,
      }
    end

    render json: insight_entries
  end

  def insight_entry_update
    return render_unauthorized_action unless @topic.user_can_access_insights?(@current_user)

    insight_entry = @topic.insight_entries.find(params[:entry_id])
    if insight_entry.nil?
      return render(json: { error: "Entry not found." }, status: :not_found)
    end

    # TODO: this parameter will change
    action = params[:relevance_human_feedback_action]
    unless %w[like dislike reset_like].include?(action)
      return render(json: { error: "Invalid action." }, status: :bad_request)
    end

    # TODO: this parameter will change
    notes = params[:relevance_human_feedback_notes]
    if notes.nil?
      return render(json: { error: "Missing notes." }, status: :bad_request)
    end

    if action == "like"
      InstStatsd::Statsd.distributed_increment("discussion_topic.insight.entry_liked")
    elsif action == "dislike"
      InstStatsd::Statsd.distributed_increment("discussion_topic.insight.entry_disliked")
    end

    insight_entry.update!(
      ai_evaluation_human_reviewer: @current_user,
      ai_evaluation_human_feedback_liked: action == "like",
      ai_evaluation_human_feedback_disliked: action == "dislike",
      ai_evaluation_human_feedback_notes: notes
    )

    render json: {}
  end

  # @API Get the full topic
  # Return a cached structure of the discussion topic, containing all entries,
  # their authors, and their message bodies.
  #
  # May require (depending on the topic) that the user has posted in the topic.
  # If it is required, and the user has not posted, will respond with a 403
  # Forbidden status and the body 'require_initial_post'.
  #
  # In some rare situations, this cached structure may not be available yet. In
  # that case, the server will respond with a 503 error, and the caller should
  # try again soon.
  #
  # The response is an object containing the following keys:
  # * "participants": A list of summary information on users who have posted to
  #   the discussion. Each value is an object containing their id, display_name,
  #   and avatar_url.
  # * "unread_entries": A list of entry ids that are unread by the current
  #   user. this implies that any entry not in this list is read.
  # * "entry_ratings": A map of entry ids to ratings by the current user. Entries
  #   not in this list have no rating. Only populated if rating is enabled.
  # * "forced_entries": A list of entry ids that have forced_read_state set to
  #   true. This flag is meant to indicate the entry's read_state has been
  #   manually set to 'unread' by the user, so the entry should not be
  #   automatically marked as read.
  # * "view": A threaded view of all the entries in the discussion, containing
  #   the id, user_id, and message.
  # * "new_entries": Because this view is eventually consistent, it's possible
  #   that newly created or updated entries won't yet be reflected in the view.
  #   If the application wants to also get a flat list of all entries not yet
  #   reflected in the view, pass include_new_entries=1 to the request and this
  #   array of entries will be returned. These entries are returned in a flat
  #   array, in ascending created_at order.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/view' \
  #        -H "Authorization: Bearer <token>"
  #
  # @example_response
  #   {
  #     "unread_entries": [1,3,4],
  #     "entry_ratings": {3: 1},
  #     "forced_entries": [1],
  #     "participants": [
  #       { "id": 10, "display_name": "user 1", "avatar_image_url": "https://...", "html_url": "https://..." },
  #       { "id": 11, "display_name": "user 2", "avatar_image_url": "https://...", "html_url": "https://..." }
  #     ],
  #     "view": [
  #       { "id": 1, "user_id": 10, "parent_id": null, "message": "...html text...", "replies": [
  #         { "id": 3, "user_id": 11, "parent_id": 1, "message": "...html....", "replies": [...] }
  #       ]},
  #       { "id": 2, "user_id": 11, "parent_id": null, "message": "...html..." },
  #       { "id": 4, "user_id": 10, "parent_id": null, "message": "...html..." }
  #     ]
  #   }
  def view
    return unless authorized_action(@topic, @current_user, :read_replies)
    return unless is_not_anonymous

    log_asset_access(@topic, "topics", "topics")

    mobile_brand_config = !in_app? && @context.account.effective_brand_config
    opts = {
      include_new_entries: value_to_boolean(params[:include_new_entries]),
      include_mobile_overrides: !!mobile_brand_config
    }
    structure, participant_ids, entry_ids, new_entries = @topic.materialized_view(opts)

    if structure
      structure = resolve_placeholders(structure)

      # we assume that json_structure will typically be served to users requesting string IDs
      if !stringify_json_ids? || mobile_brand_config
        entries = JSON.parse(structure)
        StringifyIds.recursively_stringify_ids(entries, reverse: true) unless stringify_json_ids?
        DiscussionTopic::MaterializedView.include_mobile_overrides(entries, mobile_brand_config.css_and_js_overrides) if mobile_brand_config
        structure = entries.to_json
      end

      new_entries&.each do |e|
        e["message"] = resolve_placeholders(e["message"]) if e["message"]
        e["attachments"]&.each { |att| att["url"] = resolve_placeholders(att["url"]) if att["url"] }
      end

      participants = Shard.partition_by_shard(participant_ids) do |shard_ids|
        # Preload accounts because they're needed to figure out if a user's avatar should be shown in
        # AvatarHelper#avatar_url_for_user, which is used by user_display_json. We get an N+1 on the
        # number of discussion participants if we don't do this.
        User.where(id: shard_ids).preload({ pseudonym: :account }).to_a
      end

      include_context_card_info = value_to_boolean(
        params[:include_context_card_info]
      )
      include_enrollment_state = params[:include_enrollment_state] && (@context.is_a?(Course) || @context.is_a?(Group)) &&
                                 @context.grants_right?(@current_user, session, :read_as_admin)
      if include_enrollment_state || include_context_card_info
        enrollment_context = @context.is_a?(Course) ? @context : @context.context
        all_enrollments = enrollment_context.enrollments.where(user_id: participants).to_a
        if include_enrollment_state
          Canvas::Builders::EnrollmentDateBuilder.preload_state(all_enrollments)
        end
        all_enrollments = all_enrollments.group_by(&:user_id)
      end

      all_enrollments ||= {}

      participant_info = participants.map do |participant|
        json = user_display_json(participant, @context.is_a_context? && @context)
        enrolls = all_enrollments[participant.id] || []
        if include_enrollment_state
          json[:isInactive] = enrolls.any? && enrolls.all?(&:inactive?)
        end

        if include_context_card_info
          json[:is_student] = enrolls.any? { |e| e.type == "StudentEnrollment" }
          json[:course_id] = enrollment_context.id.to_s
        end

        json
      end

      unread_entries = entry_ids - DiscussionEntryParticipant.read_entry_ids(entry_ids, @current_user)
      unread_entries = unread_entries.map(&:to_s) if stringify_json_ids?
      forced_entries = DiscussionEntryParticipant.forced_read_state_entry_ids(entry_ids, @current_user)
      forced_entries = forced_entries.map(&:to_s) if stringify_json_ids?
      entry_ratings = {}

      if @topic.allow_rating?
        entry_ratings  = DiscussionEntryParticipant.entry_ratings(entry_ids, @current_user)
        entry_ratings  = entry_ratings.transform_keys(&:to_s) if stringify_json_ids?
      end

      # as an optimization, the view structure is pre-serialized as a json
      # string, so we have to do a bit of manual json building here to fit it
      # into the response.
      fragments = {
        unread_entries: unread_entries.to_json,
        forced_entries: forced_entries.to_json,
        entry_ratings: entry_ratings.to_json,
        participants: json_cast(participant_info).to_json,
        view: structure,
        new_entries: json_cast(new_entries).to_json,
      }
      fragments = fragments.map { |k, v| %("#{k}": #{v}) }
      render json: "{ #{fragments.join(", ")} }"
    else
      head :service_unavailable
    end
  end

  # @API Post an entry
  # Create a new entry in a discussion topic. Returns a json representation of
  # the created entry (see documentation for 'entries' method) on success.
  #
  # @argument message [String] The body of the entry.
  #
  # @argument attachment a multipart/form-data form-field-style
  #   attachment. Attachments larger than 1 kilobyte are subject to quota
  #   restrictions.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/entries.json' \
  #        -F 'message=<message>' \
  #        -F 'attachment=@<filename>' \
  #        -H "Authorization: Bearer <token>"
  def add_entry
    @entry = build_entry(@topic.discussion_entries)
    if authorized_action(@topic, @current_user, :read) && authorized_action(@entry, @current_user, :create)
      save_entry
    end
  end

  # @API Duplicate discussion topic
  #
  # Duplicate a discussion topic according to context (Course/Group)
  #
  # @example_request
  #     curl -X POST -H 'Authorization: Bearer <token>' \
  #     https://<canvas>/api/v1/courses/123/discussion_topics/123/duplicate
  #
  #     curl -X POST -H 'Authorization: Bearer <token>' \
  #     https://<canvas>/api/v1/group/456/discussion_topics/456/duplicate
  #
  # @returns DiscussionTopic
  def duplicate
    # Require topic hook forbids duplicating of child, nonexistent, and deleted topics
    # The only extra check we need is to prevent duplicating announcements.
    if @topic.is_announcement
      render(json: { error: t("announcements cannot be duplicated") }, status: :bad_request) and return
    end

    return unless authorized_action(@topic, @current_user, :duplicate)

    @topic.updating_user = @current_user
    new_topic = @topic.duplicate({ user: @current_user })
    new_topic.updating_user = @current_user

    if @topic.pinned
      new_topic.position = @topic.context.discussion_topics.maximum(:position) + 1
    end
    # People that can't moderate don't have power to publish separately, so
    # just publish their topics straightaway.
    unless @context.grants_right?(@current_user, session, :moderate_forum)
      new_topic.publish
    end
    if new_topic.save!
      result = discussion_topic_api_json(new_topic,
                                         @context,
                                         @current_user,
                                         session,
                                         include_sections: true)
      # If pinned, make the new topic show up just below the old one
      if new_topic.pinned
        new_topic.insert_at(@topic.position + 1)
        # Pass the new positions to the backend so the frontend can stay consistent
        # with the backend.  Rails doesn't like topic.context.discussion_topics.select(...).
        # We only care about the id and position here, so don't pull everything else up
        positions_array = DiscussionTopic.select(:id, :position).where(context: @context)
                                         .active.where(pinned: true).map { |t| [t.id, t.position] }
        result[:new_positions] = positions_array.to_h
      end
      if new_topic.assignment
        new_topic.assignment.insert_at(@topic.assignment.position + 1)
        result[:set_assignment] = true
      end
      render json: result
    else
      render json: { error: t("unable to save new discussion topic") }, status: :bad_request
    end
  end

  # @API List topic entries
  # Retrieve the (paginated) top-level entries in a discussion topic.
  #
  # May require (depending on the topic) that the user has posted in the topic.
  # If it is required, and the user has not posted, will respond with a 403
  # Forbidden status and the body 'require_initial_post'.
  #
  # Will include the 10 most recent replies, if any, for each entry returned.
  #
  # If the topic is a root topic with children corresponding to groups of a
  # group assignment, entries from those subtopics for which the user belongs
  # to the corresponding group will be returned.
  #
  # Ordering of returned entries is newest-first by posting timestamp (reply
  # activity is ignored).
  #
  # @response_field id The unique identifier for the entry.
  #
  # @response_field user_id The unique identifier for the author of the entry.
  #
  # @response_field editor_id The unique user id of the person to last edit the entry, if different than user_id.
  #
  # @response_field user_name The name of the author of the entry.
  #
  # @response_field message The content of the entry.
  #
  # @response_field read_state The read state of the entry, "read" or "unread".
  #
  # @response_field forced_read_state Whether the read_state was forced (was set manually)
  #
  # @response_field created_at The creation time of the entry, in ISO8601
  #   format.
  #
  # @response_field updated_at The updated time of the entry, in ISO8601 format.
  #
  # @response_field attachment JSON representation of the attachment for the
  #   entry, if any. Present only if there is an attachment.
  #
  # @response_field attachments *Deprecated*. Same as attachment, but returned
  #   as a one-element array. Present only if there is an attachment.
  #
  # @response_field recent_replies The 10 most recent replies for the entry,
  #   newest first. Present only if there is at least one reply.
  #
  # @response_field has_more_replies True if there are more than 10 replies for
  #   the entry (i.e., not all were included in this response). Present only if
  #   there is at least one reply.
  #
  # @example_response
  #   [ {
  #       "id": 1019,
  #       "user_id": 7086,
  #       "user_name": "nobody@example.com",
  #       "message": "Newer entry",
  #       "read_state": "read",
  #       "forced_read_state": false,
  #       "created_at": "2011-11-03T21:33:29Z",
  #       "attachment": {
  #         "content-type": "unknown/unknown",
  #         "url": "http://www.example.com/files/681/download",
  #         "filename": "content.txt",
  #         "display_name": "content.txt" } },
  #     {
  #       "id": 1016,
  #       "user_id": 7086,
  #       "user_name": "nobody@example.com",
  #       "message": "first top-level entry",
  #       "read_state": "unread",
  #       "forced_read_state": false,
  #       "created_at": "2011-11-03T21:32:29Z",
  #       "recent_replies": [
  #         {
  #           "id": 1017,
  #           "user_id": 7086,
  #           "user_name": "nobody@example.com",
  #           "message": "Reply message",
  #           "created_at": "2011-11-03T21:32:29Z"
  #         } ],
  #       "has_more_replies": false } ]
  def entries
    return unless is_not_anonymous

    @entries = Api.paginate(root_entries(@topic).newest_first, self, entry_pagination_url(@topic))
    render json: discussion_entry_api_json(@entries, @context, @current_user, session)
  end

  # @API Post a reply
  # Add a reply to an entry in a discussion topic. Returns a json
  # representation of the created reply (see documentation for 'replies'
  # method) on success.
  #
  # May require (depending on the topic) that the user has posted in the topic.
  # If it is required, and the user has not posted, will respond with a 403
  # Forbidden status and the body 'require_initial_post'.
  #
  # @argument message [String] The body of the entry.
  #
  # @argument attachment a multipart/form-data form-field-style
  #   attachment. Attachments larger than 1 kilobyte are subject to quota
  #   restrictions.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/entries/<entry_id>/replies.json' \
  #        -F 'message=<message>' \
  #        -F 'attachment=@<filename>' \
  #        -H "Authorization: Bearer <token>"
  def add_reply
    @parent = all_entries(@topic).find(params[:entry_id])
    @entry = build_entry(@parent.discussion_subentries)
    if authorized_action(@entry, @current_user, :create)
      @entry.saving_user = @current_user
      save_entry
    end
  end

  # @API List entry replies
  # Retrieve the (paginated) replies to a top-level entry in a discussion
  # topic.
  #
  # May require (depending on the topic) that the user has posted in the topic.
  # If it is required, and the user has not posted, will respond with a 403
  # Forbidden status and the body 'require_initial_post'.
  #
  # Ordering of returned entries is newest-first by creation timestamp.
  #
  # @response_field id The unique identifier for the reply.
  #
  # @response_field user_id The unique identifier for the author of the reply.
  #
  # @response_field editor_id The unique user id of the person to last edit the entry, if different than user_id.
  #
  # @response_field user_name The name of the author of the reply.
  #
  # @response_field message The content of the reply.
  #
  # @response_field read_state The read state of the entry, "read" or "unread".
  #
  # @response_field forced_read_state Whether the read_state was forced (was set manually)
  #
  # @response_field created_at The creation time of the reply, in ISO8601
  #   format.
  #
  # @example_response
  #   [ {
  #       "id": 1015,
  #       "user_id": 7084,
  #       "user_name": "nobody@example.com",
  #       "message": "Newer message",
  #       "read_state": "read",
  #       "forced_read_state": false,
  #       "created_at": "2011-11-03T21:27:44Z" },
  #     {
  #       "id": 1014,
  #       "user_id": 7084,
  #       "user_name": "nobody@example.com",
  #       "message": "Older message",
  #       "read_state": "unread",
  #       "forced_read_state": false,
  #       "created_at": "2011-11-03T21:26:44Z" } ]
  def replies
    return unless is_not_anonymous

    @parent = root_entries(@topic).find(params[:entry_id])
    @replies = Api.paginate(reply_entries(@parent).newest_first, self, reply_pagination_url(@topic, @parent))
    render json: discussion_entry_api_json(@replies, @context, @current_user, session)
  end

  # @API List entries
  # Retrieve a paginated list of discussion entries, given a list of ids.
  #
  # May require (depending on the topic) that the user has posted in the topic.
  # If it is required, and the user has not posted, will respond with a 403
  # Forbidden status and the body 'require_initial_post'.
  #
  # @argument ids[] [String]
  #   A list of entry ids to retrieve. Entries will be returned in id order,
  #   smallest id first.
  #
  # @response_field id The unique identifier for the reply.
  #
  # @response_field user_id The unique identifier for the author of the reply.
  #
  # @response_field user_name The author's display name, or null for anonymous topics when the author is not an instructor.
  #
  # @response_field message The content of the reply.
  #
  # @response_field read_state The read state of the entry, "read" or "unread".
  #
  # @response_field forced_read_state Whether the read_state was forced (was set manually)
  #
  # @response_field created_at The creation time of the reply, in ISO8601
  #   format.
  #
  # @response_field deleted If the entry has been deleted, returns true. The
  #   user_id, user_name, and message will not be returned for deleted entries.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/entry_list?ids[]=1&ids[]=2&ids[]=3' \
  #        -H "Authorization: Bearer <token>"
  #
  # @example_response
  #   [
  #     { ... entry 1 ... },
  #     { ... entry 2 ... },
  #     { ... entry 3 ... },
  #   ]
  def entry_list
    return unless is_not_anonymous

    ids = Array(params[:ids])
    entries = @topic.discussion_entries.order(:id).find(ids)
    @entries = Api.paginate(entries, self, entry_pagination_url(@topic))
    render json: discussion_entry_api_json(@entries, @context, @current_user, session, [:display_user])
  end

  # @API Mark topic as read
  # Mark the initial text of the discussion topic as read.
  #
  # No request fields are necessary.
  #
  # On success, the response will be 204 No Content with an empty body.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/read.json' \
  #        -X PUT \
  #        -H "Authorization: Bearer <token>" \
  #        -H "Content-Length: 0"
  def mark_topic_read
    change_topic_read_state("read")
  end

  # @API Mark all topic as read
  # Mark the initial text of all the discussion topics as read in  the context.
  #
  # No request fields are necessary.
  #
  # On success, the response will be 204 No Content with an empty body.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/read_all' \
  #        -X POST \
  #        -H "Authorization: Bearer <token>" \
  #        -H "Content-Length: 0"
  def mark_all_topic_read
    scope = if params[:only_announcements] == "true"
              @context.announcements
            else
              @context.discussion_topics.only_discussion_topics.published
            end

    scope = scope.unread_for(@current_user)
                 .where.not("unlock_at > ?", Time.zone.now)
                 .or(scope.where(unlock_at: nil))

    scope.each do |announcement|
      announcement.change_read_state("read", @current_user)
    end

    head :no_content
  end

  # @API Mark topic as unread
  # Mark the initial text of the discussion topic as unread.
  #
  # No request fields are necessary.
  #
  # On success, the response will be 204 No Content with an empty body.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/read.json' \
  #        -X DELETE \
  #        -H "Authorization: Bearer <token>"
  def mark_topic_unread
    change_topic_read_state("unread")
  end

  # @API Mark all entries as read
  # Mark the discussion topic and all its entries as read.
  #
  # No request fields are necessary.
  #
  # @argument forced_read_state [Boolean]
  #   A boolean value to set all of the entries' forced_read_state. No change
  #   is made if this argument is not specified.
  #
  # On success, the response will be 204 No Content with an empty body.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/read_all.json' \
  #        -X PUT \
  #        -H "Authorization: Bearer <token>" \
  #        -H "Content-Length: 0"
  def mark_all_read
    change_topic_all_read_state("read")
  end

  # @API Mark all entries as unread
  # Mark the discussion topic and all its entries as unread.
  #
  # No request fields are necessary.
  #
  # @argument forced_read_state [Boolean]
  #   A boolean value to set all of the entries' forced_read_state. No change is
  #   made if this argument is not specified.
  #
  # On success, the response will be 204 No Content with an empty body.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/read_all.json' \
  #        -X DELETE \
  #        -H "Authorization: Bearer <token>"
  def mark_all_unread
    change_topic_all_read_state("unread")
  end

  # @API Mark entry as read
  # Mark a discussion entry as read.
  #
  # No request fields are necessary.
  #
  # @argument forced_read_state [Boolean]
  #   A boolean value to set the entry's forced_read_state. No change is made if
  #   this argument is not specified.
  #
  # On success, the response will be 204 No Content with an empty body.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/entries/<entry_id>/read.json' \
  #        -X PUT \
  #        -H "Authorization: Bearer <token>"\
  #        -H "Content-Length: 0"
  def mark_entry_read
    change_entry_read_state("read")
  end

  # @API Mark entry as unread
  # Mark a discussion entry as unread.
  #
  # No request fields are necessary.
  #
  # @argument forced_read_state [Boolean]
  #   A boolean value to set the entry's forced_read_state. No change is made if
  #   this argument is not specified.
  #
  # On success, the response will be 204 No Content with an empty body.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/entries/<entry_id>/read.json' \
  #        -X DELETE \
  #        -H "Authorization: Bearer <token>"
  def mark_entry_unread
    change_entry_read_state("unread")
  end

  # @API Rate entry
  # Rate a discussion entry.
  #
  # @argument rating [Integer]
  #   A rating to set on this entry. Only 0 and 1 are accepted.
  #
  # On success, the response will be 204 No Content with an empty body.
  #
  # @example_request
  #
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/entries/<entry_id>/rating.json' \
  #        -X POST \
  #        -H "Authorization: Bearer <token>"
  def rate_entry
    require_entry
    rating = params[:rating].to_i
    unless [0, 1].include? rating
      render(json: { message: "Invalid rating given" }, status: :bad_request) and return
    end

    if authorized_action(@entry, @current_user, :rate)
      render_state_change_result @entry.change_rating(rating, @current_user)
    end
  end

  # @API Subscribe to a topic
  # Subscribe to a topic to receive notifications about new entries
  #
  # On success, the response will be 204 No Content with an empty body
  #
  # @example_request
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/subscribed.json' \
  #        -X PUT \
  #        -H "Authorization: Bearer <token>" \
  #        -H "Content-Length: 0"
  def subscribe_topic
    render_state_change_result @topic.subscribe(@current_user)
  end

  # @API Unsubscribe from a topic
  # Unsubscribe from a topic to stop receiving notifications about new entries
  #
  # On success, the response will be 204 No Content with an empty body
  #
  # @example_request
  #   curl 'https://<canvas>/api/v1/courses/<course_id>/discussion_topics/<topic_id>/subscribed.json' \
  #        -X DELETE \
  #        -H "Authorization: Bearer <token>"
  def unsubscribe_topic
    render_state_change_result @topic.unsubscribe(@current_user)
  end

  # This is a temp endpoint for disallow_threaded_replies_fix_alert
  # TODO remove it after the alert is no longer needed
  def migrate_disallow
    raise ActiveRecord::RecordNotFound unless Account.site_admin.feature_enabled?(:disallow_threaded_replies_fix_alert)
    return render_unauthorized_action unless @context.grants_right?(@current_user, session, :moderate_forum)

    update_count = @context.active_discussion_topics
                           .only_discussion_topics
                           .where(discussion_type: DiscussionTopic::DiscussionTypes::SIDE_COMMENT)
                           .in_batches
                           .update_all(discussion_type: DiscussionTopic::DiscussionTypes::THREADED, updated_at: Time.now.utc)

    InstStatsd::Statsd.distributed_increment("discussion_topic.migrate_disallow.count")
    InstStatsd::Statsd.gauge("discussion_topic.migrate_disallow.discussions_updated", update_count)

    render json: { update_count: }
  end

  # TODO: remove it after the alert is no longer needed
  # same as migrate_disallow
  def update_discussion_types
    raise ActiveRecord::RecordNotFound unless Account.site_admin.feature_enabled?(:disallow_threaded_replies_manage)
    return render_unauthorized_action unless @context.grants_right?(@current_user, session, :moderate_forum)

    threaded = params[DiscussionTopic::DiscussionTypes::THREADED]
    not_threaded = params[DiscussionTopic::DiscussionTypes::NOT_THREADED]
    unless (threaded.is_a?(Array) && threaded.present?) ||
           (not_threaded.is_a?(Array) && not_threaded.present?)
      return render(json: { error: "Missing data" }, status: :bad_request)
    end

    side_comment_count = @context.active_discussion_topics.only_discussion_topics.where(discussion_type: DiscussionTopic::DiscussionTypes::SIDE_COMMENT).size

    to_threaded = @context.active_discussion_topics.only_discussion_topics.where(id: threaded, discussion_type: DiscussionTopic::DiscussionTypes::SIDE_COMMENT)
    to_not_threaded = @context.active_discussion_topics.only_discussion_topics.where(id: not_threaded, discussion_type: DiscussionTopic::DiscussionTypes::SIDE_COMMENT)

    if to_threaded.size != threaded.size || to_not_threaded.size != not_threaded.size || to_threaded.size + to_not_threaded.size != side_comment_count
      return render(json: { error: "Invalid data", count: side_comment_count }, status: :bad_request)
    end

    to_threaded_update_count = to_threaded.in_batches.update_all(discussion_type: DiscussionTopic::DiscussionTypes::THREADED, updated_at: Time.now.utc)
    to_not_threaded_update_count = to_not_threaded.in_batches.update_all(discussion_type: DiscussionTopic::DiscussionTypes::NOT_THREADED, updated_at: Time.now.utc)

    InstStatsd::Statsd.distributed_increment("discussion_topic.migrate_disallow_manage.count")
    InstStatsd::Statsd.distributed_increment("discussion_topic.migrate_disallow_manage.discussions_updated_count", to_threaded_update_count, tags: { type: DiscussionTopic::DiscussionTypes::THREADED })
    InstStatsd::Statsd.distributed_increment("discussion_topic.migrate_disallow_manage.discussions_updated_count", to_not_threaded_update_count, tags: { type: DiscussionTopic::DiscussionTypes::NOT_THREADED })

    render json: { success: "true" }
  end

  protected

  def cache_key
    @_cache_key ||= ["inst_llm_helper", "rate_limit", @current_user.uuid, llm_config_raw.name, Time.now.utc.strftime("%Y%m%d")].cache_key
  end

  def llm_config_raw
    @_llm_config_raw ||= LLMConfigs.config_for("discussion_topic_summary_raw")
  end

  def llm_config_refined
    @_llm_config_refined ||= LLMConfigs.config_for("discussion_topic_summary_refined")
  end

  def require_topic
    @topic = @context.all_discussion_topics.active.find(params[:topic_id])
    authorized_action(@topic, @current_user, :read)
  end

  def require_entry
    @entry = @topic.discussion_entries.find(params[:entry_id])
  end

  def require_initial_post
    return true unless @topic.initial_post_required?(@current_user, session)

    # neither the current user nor the enrollment user (if any) has posted yet,
    # so give them the forbidden status
    render json: "require_initial_post", status: :forbidden
    false
  end

  def build_entry(association)
    params[:message] = process_incoming_html_content(params[:message]) if params.key?(:message)
    @topic.saving_user = @current_user
    @topic.save! if @topic.new_record?
    association.build(message: params[:message], user: @current_user, discussion_topic: @topic)
  end

  def save_entry
    has_attachment = params[:attachment].present? && !params[:attachment].empty? &&
                     @entry.grants_right?(@current_user, session, :attach)
    return if has_attachment && !@topic.for_assignment? && params[:attachment].size > 1.kilobyte &&
              quota_exceeded(@current_user, named_context_url(@context, :context_discussion_topic_url, @topic.id))

    @entry.saving_user = @current_user
    if @entry.save
      log_asset_access(@topic, "topics", "topics", "participate")

      assignment_id = @topic.assignment_id
      submission_id = assignment_id && @topic.assignment.submission_for_student_id(@entry.user_id)&.id
      Canvas::LiveEvents.discussion_entry_submitted(@entry, assignment_id, submission_id)

      if has_attachment
        @attachment = create_attachment
        @attachment.handle_duplicates(:rename)
        @entry.attachment = @attachment
        @entry.saving_user = @current_user
        @entry.save
      end
      render json: discussion_entry_api_json([@entry], @context, @current_user, session, [:user_name, :display_user]).first, status: :created
    else
      render json: @entry.errors, status: :bad_request
    end
  end

  def create_attachment
    context = @current_user || @context
    attachment_params = {}
    if @topic.for_assignment?
      attachment_params[:folder_id] = @current_user.submissions_folder(@context)
      context = @current_user
    end
    attachment = context.attachments.new(attachment_params)
    Attachments::Storage.store_for_attachment(attachment, params[:attachment])
    attachment.save!
    attachment
  end

  def visible_topics(topic)
    # conflate entries from all child topics for groups the user can access
    topics = [topic]
    if topic.for_group_discussion? && !topic.child_topics.empty?
      groups = topic.group_category.groups.active.select do |group|
        group.grants_right?(@current_user, session, :read)
      end
      topic.child_topics.each { |t| topics << t if groups.include?(t.context) }
    end
    topics
  end

  def all_entries(topic)
    topic.shard.activate do
      DiscussionEntry.all_for_topics(visible_topics(topic)).active
    end
  end

  def root_entries(topic)
    topic.shard.activate do
      DiscussionEntry.top_level_for_topics(visible_topics(topic)).active
    end
  end

  def reply_entries(entry)
    entry.flattened_discussion_subentries.active
  end

  def change_topic_read_state(new_state)
    render_state_change_result @topic.change_read_state(new_state, @current_user)
  end

  def get_forced_option
    opts = {}
    opts[:forced] = value_to_boolean(params[:forced_read_state]) if params.key?(:forced_read_state)
    opts
  end

  def change_topic_all_read_state(new_state)
    opts = get_forced_option

    @topic.change_all_read_state(new_state, @current_user, opts)
    render json: {}, status: :no_content
  end

  def change_entry_read_state(new_state)
    require_entry
    opts = get_forced_option

    if authorized_action(@entry, @current_user, :read)
      render_state_change_result @entry.change_read_state(new_state, @current_user, opts)
    end
  end

  # the result of several state change functions are the following:
  #  nil - no current user
  #  true - state is already set to the requested state
  #  participant with errors - something went wrong with the participant
  #  participant with no errors - the change went through
  # this function renders a 204 No Content for a success, or a Bad Request
  # for failure with participant errors if there are any
  def render_state_change_result(result)
    if result == true || result.try(:errors).blank?
      head :no_content
    else
      render json: result.try(:errors) || {}, status: :bad_request
    end
  end

  def is_not_anonymous
    if @topic.anonymous?
      render json: { errors: [{ message: "The specified resource does not exist." }] }, status: :not_found

      return false
    end

    true
  end

  def generate_focus(user_input)
    DiscussionTopic::PromptPresenter.focus_for_summary(user_input:)
  end

  def generate_raw_dynamic_content(focus)
    raw_dynamic_content = {
      CONTENT: DiscussionTopic::PromptPresenter.new(@topic).content_for_summary,
      FOCUS: focus
    }

    { content: raw_dynamic_content, hash: Digest::SHA256.hexdigest(raw_dynamic_content.to_json) }
  end

  def generate_refined_dynamic_content(focus, raw_summary)
    locale = I18n.locale.to_s
    pretty_locale = available_locales[locale] || "English"
    refined_dynamic_content = {
      CONTENT: DiscussionTopic::PromptPresenter.raw_summary_for_refinement(raw_summary: raw_summary.summary),
      FOCUS: focus,
      LOCALE: pretty_locale
    }

    { content: refined_dynamic_content, hash: Digest::SHA256.hexdigest(refined_dynamic_content.to_json) }
  end

  def fetch_or_create_summary(llm_config:, dynamic_content:, dynamic_content_hash:, user_input:, parent_summary: nil, locale: nil)
    summary = @topic.summaries.where(llm_config_version: llm_config.name, dynamic_content_hash:, parent: parent_summary)
                    .order(created_at: :desc)
                    .first
    return summary if summary

    prompt, options = llm_config.generate_prompt_and_options(substitutions: dynamic_content)
    content, input_tokens, output_tokens, generation_time = generate_llm_response(llm_config, prompt, options)

    @topic.summaries.create!(
      llm_config_version: llm_config.name,
      dynamic_content_hash:,
      user: @current_user,
      user_input:,
      summary: content,
      input_tokens:,
      output_tokens:,
      generation_time:,
      parent: parent_summary,
      locale:
    )
  end

  def generate_llm_response(llm_config, prompt, options)
    response = nil
    time = Benchmark.measure do
      InstLLMHelper.with_rate_limit(user: @current_user, llm_config:) do
        response = InstLLMHelper.client(llm_config.model_id).chat(
          [{ role: "user", content: prompt }],
          **options.symbolize_keys
        )
      end
    end

    [
      response.message[:content],
      response.usage[:input_tokens],
      response.usage[:output_tokens],
      time.real.round(2)
    ]
  end
end
