# Computes per-assistant overview metrics for the Captain Overview page.
# Each metric is returned for the current window and the previous equal-length
# window, plus a derived trend.
#
# Queries are batched to cut round trips: the message-derived counts (handled,
# public replies, depth) are computed for both windows in a single scan via
# conditional FILTER aggregation.
class Captain::AssistantStatsBuilder
  RESOLVED_EVENT_NAMES = %w[conversation_captain_inference_resolved conversation_bot_resolved].freeze
  HANDOFF_EVENT_NAMES = %w[conversation_captain_inference_handoff conversation_bot_handoff].freeze
  BOT_RESOLVED_EVENT_NAME = 'conversation_bot_resolved'.freeze

  # Assumed agent effort displaced by each public assistant reply. Reporting data
  # only captures reply latency (customer wait time), not handling effort, so hours
  # saved is a count-times-assumed-effort estimate rather than a measured duration.
  SECONDS_SAVED_PER_REPLY = 2.minutes.to_i

  attr_reader :assistant, :account

  delegate :range, :period, to: :window

  # `range` is either a day count ('7', '30', '90') or a named period
  # ('this_month', 'last_month'). `timezone_offset` is the viewer's UTC offset in
  # hours (as the reports API sends it), so month/day boundaries anchor to the
  # viewer's day rather than UTC. Both windows are resolved by AssistantStatsWindow.
  def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil)
    @assistant = assistant
    @account = assistant.account
    @window = Captain::AssistantStatsWindow.new(range, timezone_offset)
  end

  def metrics
    messages = message_window_metrics
    current = window_metrics(current_range, messages[:current])
    previous = window_metrics(previous_range, messages[:previous])

    build_metrics(current, previous)
  end

  private

  attr_reader :window

  def current_range
    window.current
  end

  def previous_range
    window.previous
  end

  def build_metrics(current, previous)
    {
      conversations_handled: pack(current[:handled], previous[:handled], :percent),
      auto_resolution_rate: pack(current[:auto_resolution], previous[:auto_resolution], :point),
      handoff_rate: pack(current[:handoff], previous[:handoff], :point),
      hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
      reopen_rate: pack(current[:reopen], previous[:reopen], :point),
      conversation_depth: pack(current[:depth], previous[:depth], :absolute),
      knowledge: knowledge
    }
  end

  # Combines the per-window message counts with the reporting-event metrics for one window.
  def window_metrics(range, message_counts)
    handled = message_counts[:handled]
    public_count = message_counts[:public_count]
    depth_conversations = message_counts[:depth_conversations]
    resolution = resolution_counts(range)

    {
      handled: handled,
      auto_resolution: rate(resolution[:resolved], handled),
      handoff: rate(resolution[:handoff], handled),
      hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
      reopen: reopen_rate(range),
      depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
    }
  end

  # One scan over the assistant's messages computes handled, public-reply count,
  # and depth-conversation count for both windows via conditional aggregation.
  def message_window_metrics
    public_clause = "message_type = #{Message.message_types[:outgoing]} AND private = false"
    cur = window_clause(current_range)
    prev = window_clause(previous_range)

    row = handled_scope(full_span).reorder(nil).pick(
      Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur})"),
      Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev})"),
      Arel.sql("COUNT(*) FILTER (WHERE #{cur} AND #{public_clause})"),
      Arel.sql("COUNT(*) FILTER (WHERE #{prev} AND #{public_clause})"),
      Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur} AND #{public_clause})"),
      Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev} AND #{public_clause})")
    )

    {
      current: { handled: row[0], public_count: row[2], depth_conversations: row[4] },
      previous: { handled: row[1], public_count: row[3], depth_conversations: row[5] }
    }
  end

  # Resolved and handed-off conversation counts for one window, in a single scan
  # of the handled set's reporting events.
  def resolution_counts(range)
    row = account.reporting_events
                 .where(name: RESOLVED_EVENT_NAMES + HANDOFF_EVENT_NAMES,
                        created_at: range,
                        conversation_id: handled_scope(range).select(:conversation_id))
                 .reorder(nil)
                 .pick(
                   Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{resolved_clause(range)})"),
                   Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE name IN (#{quoted(HANDOFF_EVENT_NAMES)}))")
                 )
    { resolved: row[0], handoff: row[1] }
  end

  # A countable resolve is any inference resolve, or a bot resolve on a conversation
  # with no handoff in the window. conversation_bot_resolved fires on any resolve
  # without an agent message (reporting_event_listener), so a handed-off conversation
  # that goes quiet and gets closed would otherwise count as an auto-resolution too;
  # the reports bot_resolutions metric applies the same exclusion (:exclude_bot_handoffs).
  def resolved_clause(range)
    "name IN (#{quoted(RESOLVED_EVENT_NAMES)}) AND #{bot_resolve_handoff_exclusion(range)}"
  end

  def bot_resolve_handoff_exclusion(range)
    "NOT (name = #{quote(BOT_RESOLVED_EVENT_NAME)} AND conversation_id IN (#{handoff_conversation_ids(range).to_sql}))"
  end

  def handoff_conversation_ids(range)
    account.reporting_events.where(name: HANDOFF_EVENT_NAMES, created_at: range).select(:conversation_id)
  end

  # Conversations the assistant participated in (authored any message).
  def handled_scope(range)
    account.messages.where(sender_type: 'Captain::Assistant', sender_id: assistant.id, created_at: range)
  end

  # Span covering both windows so a single scan can split them with FILTER.
  def full_span
    [current_range.first, previous_range.first].min..current_range.last
  end

  def window_clause(range)
    "created_at >= #{quote(range.first)} AND created_at <= #{quote(range.last)}"
  end

  def quote(value)
    account.class.connection.quote(value)
  end

  def quoted(values)
    values.map { |value| quote(value) }.join(', ')
  end

  # Of the conversations Captain auto-resolved, the share reopened afterwards. The cohort is
  # derived from the assistant's handled conversations (not current inbox membership) so a later
  # inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
  # and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
  def reopen_rate(range)
    resolved_scope = account.reporting_events
                            .where(name: RESOLVED_EVENT_NAMES, created_at: range,
                                   conversation_id: handled_scope(range).select(:conversation_id))
                            .where(bot_resolve_handoff_exclusion(range))
    # event_end_time on a reopen is when it actually reopened. Join it to the conversation's own
    # Captain resolves and keep only reopens at/after one of them, so a human resolve/reopen earlier
    # in the same window isn't mistaken for a reopen-after-Captain-resolve. (Comparing the reopen's
    # start time instead would misfire: the inference event is dispatched just after the generic
    # conversation_resolved that seeds event_start_time, so it can land after the reopen's start.)
    # The reopen itself must also fall inside the window, so a completed range (last_month, the
    # previous window) doesn't count reopens that happened after it ended.
    reopened = account.reporting_events
                      .where(name: 'conversation_opened')
                      .where('reporting_events.value > 0')
                      .where('reporting_events.event_end_time <= ?', range.last)
                      .joins("INNER JOIN (#{resolved_scope.to_sql}) resolves " \
                             'ON resolves.conversation_id = reporting_events.conversation_id ' \
                             'AND reporting_events.event_end_time >= resolves.event_end_time')
                      .distinct.count('reporting_events.conversation_id')
    rate(reopened, resolved_scope.distinct.count(:conversation_id))
  end

  # Approved/pending FAQ counts and the document total in a single round trip.
  def knowledge
    approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
      Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
      Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
      Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
    )
    total = approved + pending

    {
      approved: approved,
      pending: pending,
      documents: documents,
      coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
    }
  end

  def rate(numerator, denominator)
    return 0 if denominator.zero?

    (numerator.to_f / denominator * 100).round(1)
  end

  def pack(current, previous, mode)
    { current: current, previous: previous, trend: trend(current, previous, mode) }
  end

  def trend(current, previous, mode)
    case mode
    when :percent
      previous.zero? ? 0 : ((current - previous).to_f / previous * 100).round(1)
    else # :point and :absolute are both current - previous
      (current - previous).round(1)
    end
  end
end
