# frozen_string_literal: true

# name: discourse-events
# about: Adds the ability to create a dynamic calendar with events in a topic.
# meta_topic_id: 97376
# version: 0.5
# author: Daniel Waterworth, Joffrey Jaffeux
# url: https://github.com/discourse/discourse/tree/main/plugins/discourse-events

libdir = File.join(File.dirname(__FILE__), "vendor/holidays/lib")
$LOAD_PATH.unshift(libdir) if $LOAD_PATH.exclude?(libdir)

require_relative "lib/discourse_events/configuration/time_of_day_validator"
require_relative "lib/discourse_events/configuration/event_custom_fields_validator"
require_relative "lib/discourse_events/configuration/first_day_of_week"
require_relative "lib/discourse_events/configuration/upcoming_events_default_view"

enabled_site_setting :discourse_events_enabled

register_svg_icon "calendar-days"
register_asset "stylesheets/common/full-calendar-ext.scss"
register_asset "stylesheets/common/discourse-calendar.scss"
register_asset "stylesheets/common/discourse-calendar-holidays.scss"
register_asset "stylesheets/common/discourse-post-event.scss"
register_asset "stylesheets/common/discourse-post-event-preview.scss"
register_asset "stylesheets/common/post-event-builder.scss"
register_asset "stylesheets/common/discourse-post-event-invitees.scss"
register_asset "stylesheets/common/composer-event-node-view.scss"
register_asset "stylesheets/common/discourse-post-event-core-ext.scss"
register_asset "stylesheets/mobile/discourse-post-event-core-ext.scss", :mobile
register_asset "stylesheets/common/discourse-post-event-bulk-invite-modal.scss"
register_asset "stylesheets/mobile/discourse-calendar.scss", :mobile
register_asset "stylesheets/mobile/discourse-post-event.scss", :mobile
register_asset "stylesheets/colors.scss", :color_definitions
register_asset "stylesheets/common/user-preferences.scss"
register_asset "stylesheets/common/upcoming-events-list.scss"
register_asset "stylesheets/common/livestream.scss"
register_asset "stylesheets/desktop/livestream.scss", :desktop
register_asset "stylesheets/mobile/livestream.scss", :mobile
register_svg_icon "calendar-day"
register_svg_icon "clock"
register_svg_icon "file-csv"
register_svg_icon "star"
register_svg_icon "file-arrow-up"
register_svg_icon "location-pin"
register_svg_icon "arrows-up-to-line"
extend_content_security_policy(worker_src: %w[https://source.zoom.us blob:])

module ::DiscourseEvents
  # The plugin's identity slug — matches the `name:` metadata above. Used by
  # requires_plugin, engine_name, settings attribution, and the admin UI.
  PLUGIN_NAME = "discourse-events"

  # Frozen on the original slug: the key in plugin_store_rows.plugin_name for
  # existing "users on holiday" data. Renaming it would orphan those rows.
  PLUGIN_STORE_NAME = "discourse-calendar"

  # Frozen on the original slug: the value in user_api_key_scopes.name for every
  # existing calendar-feed subscription key. Same reasoning as PLUGIN_STORE_NAME.
  EVENTS_CALENDAR_SCOPE = "discourse-calendar:events_calendar"

  # Type of calendar ('static' or 'dynamic')
  CALENDAR_CUSTOM_FIELD = "calendar"

  # User custom field set when user is on holiday
  HOLIDAY_CUSTOM_FIELD = "on_holiday"

  # List of all users on holiday
  USERS_ON_HOLIDAY_KEY = "users_on_holiday"

  # User region used in finding holidays
  REGION_CUSTOM_FIELD = "holidays-region"

  # List of groups
  GROUP_TIMEZONES_CUSTOM_FIELD = "group-timezones"

  module Livestream
    LIVESTREAM_CHAT_STATUS_MESSAGE_BUS_CHANNEL = "/discourse-calendar/livestream/chat-status"

    def self.handle_topic_chat_channel_creation(topic)
      return if topic.category.blank?
      return if DiscourseEvents::Livestream::TopicChatChannel.exists?(topic_id: topic.id)
      return unless topic.first_post&.event&.livestream?

      channel =
        Chat::Channel.create!(
          chatable_id: topic.category.id,
          chatable_type: "Category",
          name: topic.title,
          emoji: "spiral_calendar",
          status: Chat::Channel.statuses[:open],
          type: "CategoryChannel",
          allow_channel_wide_mentions: true,
        )

      DiscourseEvents::Livestream::TopicChatChannel.create!(topic: topic, chat_channel: channel)
      channel.user_chat_channel_memberships.create!(user: topic.user, following: false)
      pin_topic_reference_message(topic, channel)
    end

    def self.pin_topic_reference_message(topic, channel)
      guardian = Discourse.system_user.guardian
      message = nil

      Chat::CreateMessage.call(
        guardian:,
        params: {
          chat_channel_id: channel.id,
          message:
            I18n.t(
              "discourse_events.livestream.chat.topic_reference_message",
              title: topic.markdown_link_title,
              url: topic.relative_url,
            ),
        },
        options: {
          enforce_membership: true,
        },
      ) do |create_result|
        on_success { |message_instance:| message = message_instance }
        on_failure do
          Rails.logger.warn(
            "Failed to create livestream topic reference message for channel #{channel.id}: #{create_result.inspect_steps}",
          )
        end
      end

      return if message.blank?

      DiscourseEvents::Livestream::TopicChatChannel.where(chat_channel_id: channel.id).update_all(
        reference_message_id: message.id,
      )

      return if !SiteSetting.chat_pinned_messages

      Chat::PinMessage.call(
        guardian:,
        params: {
          message_id: message.id,
          channel_id: channel.id,
        },
      ) do |pin_result|
        on_failure do
          Rails.logger.warn(
            "Failed to pin livestream topic reference message for channel #{channel.id}: #{pin_result.inspect_steps}",
          )
        end
      end
    rescue StandardError => e
      Rails.logger.warn(
        "Failed to post livestream topic reference message for channel #{channel.id}: #{e.message}",
      )
    end

    def self.livestream_chat_status_channel(user_id)
      "#{LIVESTREAM_CHAT_STATUS_MESSAGE_BUS_CHANNEL}/#{user_id}"
    end

    def self.publish_livestream_chat_status(membership, user:)
      MessageBus.publish(
        livestream_chat_status_channel(user.id),
        Chat::UserChannelMembershipSerializer.new(membership, scope: user.guardian).to_json,
        user_ids: [user.id],
      )
    end

    class ChannelSerializationContext
      def initialize(user)
        @user = user
      end

      def invitees_by_post_id
        return {} if @user.nil?

        @invitees_by_post_id ||=
          DiscourseEvents::Events::Invitee.where(user_id: @user.id).index_by(&:post_id)
      end

      def group_names
        return [] if @user.nil?

        @group_names ||= @user.groups.pluck(:name)
      end
    end

    module ChannelSerializerExtension
      private

      def livestream_serialization_context
        @livestream_serialization_context ||=
          @options[:livestream_context] ||
            DiscourseEvents::Livestream::ChannelSerializationContext.new(scope.user)
      end

      def livestream_invitees_by_post_id
        livestream_serialization_context.invitees_by_post_id
      end

      def livestream_user_group_names
        livestream_serialization_context.group_names
      end
    end
  end

  def self.users_on_holiday
    PluginStore.get(PLUGIN_STORE_NAME, USERS_ON_HOLIDAY_KEY) || []
  end

  def self.users_on_holiday=(usernames)
    PluginStore.set(PLUGIN_STORE_NAME, USERS_ON_HOLIDAY_KEY, usernames)
  end
end

module ::DiscourseEvents
  module Events
    # Topic where op has a post event custom field
    TOPIC_POST_EVENT_STARTS_AT = "TopicEventStartsAt"
    TOPIC_POST_EVENT_ENDS_AT = "TopicEventEndsAt"
    TOPIC_POST_EVENT_ALL_DAY = "TopicEventAllDay"
  end
end

require_relative "lib/discourse_events/engine"
require_relative "lib/discourse_events/livestream/allowed_hosts"
require_relative "lib/discourse_events/livestream/allowed_hosts_validator"
require_relative "lib/discourse_events/livestream/topic_extension"
require_relative "lib/discourse_events/livestream/chat_channel_extension"
require_relative "lib/discourse_events/livestream/zoom_url_parser"

Dir
  .glob(File.expand_path("../lib/discourse_events/configuration/*.rb", __FILE__))
  .each { |f| require(f) }

after_initialize do
  if respond_to?(:register_discourse_workflows_node)
    register_discourse_workflows_node do
      [
        DiscourseWorkflows::Nodes::EventEnded::V1,
        DiscourseWorkflows::Nodes::EventParticipationChanged::V1,
      ]
    end
  end

  reloadable_patch do
    register_category_type(DiscourseEvents::Categories::Types::Events)
    Category.register_custom_field_type("sort_topics_by_event_start_date", :boolean)
    Category.register_custom_field_type("disable_topic_resorting", :boolean)
    register_preloaded_category_custom_fields("sort_topics_by_event_start_date")
    register_preloaded_category_custom_fields("disable_topic_resorting")
  end

  add_to_serializer :basic_category, :sort_topics_by_event_start_date do
    object.custom_fields["sort_topics_by_event_start_date"]
  end

  add_to_serializer :basic_category, :disable_topic_resorting do
    object.custom_fields["disable_topic_resorting"]
  end

  reloadable_patch do
    TopicQuery.add_custom_filter(:order_by_event_date) do |results, topic_query|
      if SiteSetting.sort_categories_by_event_start_date_enabled &&
           topic_query.options[:category_id]
        category = Category.find_by(id: topic_query.options[:category_id])
        if category && category.custom_fields &&
             category.custom_fields["sort_topics_by_event_start_date"]
          reorder_sql = <<~SQL
           CASE WHEN COALESCE(custom_fields.value::timestamptz, topics.bumped_at) > NOW() THEN 0 ELSE 1 END,
           CASE WHEN COALESCE(custom_fields.value::timestamptz, topics.bumped_at) > NOW() THEN COALESCE(custom_fields.value::timestamptz, topics.bumped_at) ELSE NULL END,
           CASE WHEN COALESCE(custom_fields.value::timestamptz, topics.bumped_at) < NOW() THEN COALESCE(custom_fields.value::timestamptz, topics.bumped_at) ELSE NULL END DESC
          SQL
          results =
            results.joins(
              "LEFT JOIN topic_custom_fields AS custom_fields on custom_fields.topic_id = topics.id
         AND custom_fields.name = '#{DiscourseEvents::Events::TOPIC_POST_EVENT_STARTS_AT}'
         ",
            ).reorder(reorder_sql)
        end
      end
      results
    end
  end

  # DISCOURSE CALENDAR HOLIDAYS

  add_admin_route "discourse_events.title", "discourse-events", use_new_show_route: true

  # DISCOURSE POST EVENT

  require_relative "jobs/regular/discourse_post_event/bulk_invite"
  require_relative "jobs/regular/discourse_post_event/bump_topic"
  require_relative "jobs/regular/discourse_post_event/send_reminder"
  require_relative "jobs/regular/discourse_post_event/warm_livestream_onebox"
  require_relative "lib/discourse_events/events/chat_channel_sync"
  require_relative "lib/discourse_events/events/email_renderer"
  require_relative "lib/discourse_events/events/excerpt"
  require_relative "lib/discourse_events/events/finder"
  require_relative "lib/discourse_events/events/onebox_data"
  require_relative "lib/discourse_events/events/parser"
  require_relative "lib/discourse_events/events/validator"
  require_relative "lib/discourse_events/events/export_csv_controller_extension"
  require_relative "lib/discourse_events/events/export_csv_report_extension"
  require_relative "lib/discourse_events/events/guardian_extensions"
  require_relative "lib/discourse_events/events/post_extension"
  require_relative "lib/discourse_events/events/topic_extension"
  require_relative "lib/discourse_events/events/rrule_generator"
  require_relative "lib/discourse_events/events/rrule_configurator"
  require_relative "lib/discourse_events/events/web_hook_extension"

  ::ActionController::Base.prepend_view_path File.expand_path("../app/views", __FILE__)

  add_api_parameter_route(methods: :get, actions: "discourse_events/events#index", formats: :ics)

  # Register the calendar-feed scope explicitly under its frozen name (see
  # EVENTS_CALENDAR_SCOPE). We can't use `add_user_api_key_scope` here: it derives
  # the scope prefix from the plugin's `name:` (now "discourse-events"), which would
  # no longer match the value persisted in existing subscription keys.
  DiscoursePluginRegistry.register_user_api_key_scope_mapping(
    {
      DiscourseEvents::EVENTS_CALENDAR_SCOPE.to_sym => [
        RouteMatcher.new(methods: :get, actions: "discourse_events/events#index", formats: :ics),
      ],
    },
    self,
  )

  register_calendar_subscription_feed(
    name: "all_events",
    scope: DiscourseEvents::EVENTS_CALENDAR_SCOPE,
    description_key: "discourse_events.preferences.all_events_description",
    url: ->(base_url, _user, key) do
      "#{base_url}/discourse-post-event/events.ics?user_api_key=#{key}"
    end,
  )

  register_calendar_subscription_feed(
    name: "my_events",
    scope: DiscourseEvents::EVENTS_CALENDAR_SCOPE,
    description_key: "discourse_events.preferences.my_events_description",
    url: ->(base_url, user, key) do
      "#{base_url}/discourse-post-event/events.ics?attending_user=#{user.username_lower}&include_interested=true&user_api_key=#{key}"
    end,
  )

  reloadable_patch do
    ExportCsvController.prepend(DiscourseEvents::Events::ExportCsvControllerExtension)
    Jobs::ExportCsvFile.prepend(DiscourseEvents::Events::ExportCsvReportExtension)
    Guardian.prepend(DiscourseEvents::Events::GuardianExtensions)
    Post.prepend(DiscourseEvents::Events::PostExtension)
    ::WebHook.prepend(DiscourseEvents::Events::WebHookExtension)
    Topic.prepend(DiscourseEvents::Events::TopicExtension)
    Topic.prepend(DiscourseEvents::Livestream::TopicExtension)
    Chat::Channel.prepend(DiscourseEvents::Livestream::ChatChannelExtension)
  end

  add_to_serializer(:current_user, :can_create_discourse_post_event) do
    scope.can_create_discourse_post_event?
  end

  add_class_method(:group, :discourse_post_event_allowed_groups) do
    where(id: SiteSetting.discourse_post_event_allowed_on_groups_map)
  end

  TopicView.on_preload do |topic_view|
    if SiteSetting.discourse_post_event_enabled
      topic_view.instance_variable_set(
        :@posts,
        topic_view.posts.includes(event: [:image_upload, { event_hosts: :user }]),
      )
    end
  end

  add_to_serializer(
    :topic_view,
    :discourse_post_event_first_post_event,
    include_condition: -> do
      first_post = object.topic.first_post
      event = first_post&.event

      Array(object.posts).none? { |post| post.post_number == 1 } && event.present? &&
        event.deleted_at.nil? && scope.can_see?(first_post)
    end,
  ) do
    first_post = object.topic.first_post
    event =
      DiscourseEvents::Events::Event.includes(:image_upload, { event_hosts: :user }).find_by(
        id: first_post.id,
      )

    DiscourseEvents::Events::EventSerializer.new(event, scope: scope, root: false).as_json
  end

  add_to_serializer(
    :post,
    :event,
    include_condition: -> do
      SiteSetting.discourse_post_event_enabled && !object.nil? && !object.deleted_at.present?
    end,
  ) { DiscourseEvents::Events::EventSerializer.new(object.event, scope: scope, root: false) }

  TopicView.on_preload do |topic_view|
    if SiteSetting.discourse_post_event_enabled
      # always set the store (even when empty) and avoid a per-post query for every post in the topic
      topic_view.set_preloaded_post_data(
        :event_oneboxes,
        DiscourseEvents::Events::OneboxData.build(
          posts: topic_view.posts,
          guardian: topic_view.guardian,
        ),
      )
    end
  end

  add_to_serializer(
    :post,
    :event_oneboxes,
    include_condition: -> { SiteSetting.discourse_post_event_enabled && event_oneboxes.present? },
  ) do
    # use the batched topic-view preload on the common read path
    # otherwise compute just this post so the event card shows without a page refresh
    @event_oneboxes ||=
      begin
        preloaded = topic_view&.preloaded_post_data(:event_oneboxes)
        if preloaded
          preloaded[object.id] || {}
        elsif object.cooked&.include?("data-topic")
          DiscourseEvents::Events::OneboxData.build(posts: [object], guardian: scope)[object.id] ||
            {}
        else
          {}
        end
      end
  end

  on(:post_created) do |post|
    DiscourseEvents::Events::Event::SyncFromPost.call(params: { post_id: post.id })
    post.association(:event).reload
    if SiteSetting.discourse_post_event_enabled && post.event
      WebHook.enqueue_calendar_event_hooks(:calendar_event_created, post.event)
    end
  end

  on(:post_edited) do |post|
    event_before = post.event
    had_image_before = event_before&.image_upload_id.present?
    DiscourseEvents::Events::Event::SyncFromPost.call(params: { post_id: post.id })
    post.association(:event).reload

    if SiteSetting.discourse_post_event_enabled
      if post.event&.image_upload_id
        post.event.sync_image_to_post_and_topic
      elsif had_image_before
        post.trigger_post_process
      end
      DiscourseEvents::Events::Event.handle_post_event_webhooks(post, event_before)
    end
  end

  on(:post_destroyed) do |post|
    if SiteSetting.discourse_post_event_enabled && post.event
      payload = WebHook.build_calendar_event_payload(post.event)
      post.event.update!(deleted_at: Time.now)
      WebHook.enqueue_calendar_event_hooks(:calendar_event_destroyed, post.event, payload)
    end
  end

  on(:post_recovered) do |post|
    if SiteSetting.discourse_post_event_enabled && post.event
      post.event.update!(deleted_at: nil)
      WebHook.enqueue_calendar_event_hooks(:calendar_event_created, post.event)
    end
  end

  add_preloaded_topic_list_custom_field DiscourseEvents::Events::TOPIC_POST_EVENT_STARTS_AT

  add_to_serializer(
    :topic_view,
    :event_starts_at,
    include_condition: -> do
      SiteSetting.discourse_post_event_enabled &&
        SiteSetting.display_post_event_date_on_topic_title &&
        object.topic.custom_fields.keys.include?(
          DiscourseEvents::Events::TOPIC_POST_EVENT_STARTS_AT,
        )
    end,
  ) { object.topic.event_starts_at }

  add_to_class(:topic, :event_starts_at) do
    @event_starts_at ||=
      begin
        value = custom_fields[DiscourseEvents::Events::TOPIC_POST_EVENT_STARTS_AT].to_s
        Time.find_zone("UTC").parse(value) if value.present?
      end
  end

  add_to_serializer(
    :topic_list_item,
    :event_starts_at,
    include_condition: -> do
      SiteSetting.discourse_post_event_enabled &&
        SiteSetting.display_post_event_date_on_topic_title && object.event_starts_at
    end,
  ) { object.event_starts_at }

  add_preloaded_topic_list_custom_field DiscourseEvents::Events::TOPIC_POST_EVENT_ENDS_AT

  add_to_serializer(
    :topic_view,
    :event_ends_at,
    include_condition: -> do
      SiteSetting.discourse_post_event_enabled &&
        SiteSetting.display_post_event_date_on_topic_title &&
        object.topic.custom_fields.keys.include?(DiscourseEvents::Events::TOPIC_POST_EVENT_ENDS_AT)
    end,
  ) { object.topic.event_ends_at }

  add_to_class(:topic, :event_ends_at) do
    @event_ends_at ||=
      begin
        value = custom_fields[DiscourseEvents::Events::TOPIC_POST_EVENT_ENDS_AT].to_s
        Time.find_zone("UTC").parse(value) if value.present?
      end
  end

  add_to_serializer(
    :topic_list_item,
    :event_ends_at,
    include_condition: -> do
      SiteSetting.discourse_post_event_enabled &&
        SiteSetting.display_post_event_date_on_topic_title && object.event_ends_at
    end,
  ) { object.event_ends_at }

  add_preloaded_topic_list_custom_field DiscourseEvents::Events::TOPIC_POST_EVENT_ALL_DAY

  add_to_serializer(
    :topic_view,
    :event_all_day,
    include_condition: -> do
      SiteSetting.discourse_post_event_enabled &&
        SiteSetting.display_post_event_date_on_topic_title && object.topic.event_all_day
    end,
  ) { object.topic.event_all_day }

  add_to_class(:topic, :event_all_day) do
    return @event_all_day if defined?(@event_all_day)
    @event_all_day =
      begin
        value = custom_fields[DiscourseEvents::Events::TOPIC_POST_EVENT_ALL_DAY].to_s
        ActiveModel::Type::Boolean.new.cast(value)
      end
  end

  add_to_serializer(
    :topic_list_item,
    :event_all_day,
    include_condition: -> do
      SiteSetting.discourse_post_event_enabled &&
        SiteSetting.display_post_event_date_on_topic_title && object.event_all_day
    end,
  ) { object.event_all_day }

  add_to_serializer(
    :topic_view,
    :event_timezone,
    include_condition: -> do
      SiteSetting.discourse_post_event_enabled &&
        SiteSetting.display_post_event_date_on_topic_title &&
        object.topic.first_post&.event&.timezone.present?
    end,
  ) { object.topic.first_post.event.timezone }

  add_to_serializer(
    :topic_view,
    :event_show_local_time,
    include_condition: -> do
      SiteSetting.discourse_post_event_enabled &&
        SiteSetting.display_post_event_date_on_topic_title &&
        object.topic.first_post&.event.present?
    end,
  ) { object.topic.first_post.event.show_local_time }

  # DISCOURSE CALENDAR

  require_relative "jobs/scheduled/create_holiday_events"
  require_relative "jobs/scheduled/delete_expired_event_posts"
  require_relative "jobs/scheduled/monitor_event_dates"
  require_relative "jobs/scheduled/update_holiday_usernames"

  register_post_custom_field_type(DiscourseEvents::CALENDAR_CUSTOM_FIELD, :string)
  register_post_custom_field_type(DiscourseEvents::GROUP_TIMEZONES_CUSTOM_FIELD, :json)
  TopicView.default_post_custom_fields << DiscourseEvents::GROUP_TIMEZONES_CUSTOM_FIELD

  register_user_custom_field_type(DiscourseEvents::HOLIDAY_CUSTOM_FIELD, :boolean)

  allow_staff_user_custom_field(DiscourseEvents::HOLIDAY_CUSTOM_FIELD)
  DiscoursePluginRegistry.serialized_current_user_fields << DiscourseEvents::REGION_CUSTOM_FIELD
  register_editable_user_custom_field(DiscourseEvents::REGION_CUSTOM_FIELD)
  register_user_custom_field_type(DiscourseEvents::REGION_CUSTOM_FIELD, :string, max_length: 40)

  on(:site_setting_changed) do |name, old_value, new_value|
    next if %i[all_day_event_start_time all_day_event_end_time].exclude? name

    Post
      .where(id: DiscourseEvents::Calendar::Event.select(:post_id).distinct)
      .each { |post| DiscourseEvents::Calendar::Event.update(post) }
  end

  on(:post_process_cooked) do |doc, post|
    DiscourseEvents::Calendar::Extractor.update(post)
    DiscourseEvents::GroupTimezones::Extractor.update(post)
    DiscourseEvents::Calendar::Event.update(post)

    if SiteSetting.discourse_post_event_enabled
      event = DiscourseEvents::Events::Event.find_by(id: post.id)
      event&.sync_image_to_post_and_topic(generate_thumbnails: true) if event&.image_upload_id
    end
  end

  on(:post_recovered) do |post, _, _|
    DiscourseEvents::Calendar::Extractor.update(post)
    DiscourseEvents::GroupTimezones::Extractor.update(post)
    DiscourseEvents::Calendar::Event.update(post)
  end

  on(:post_destroyed) do |post, _, _|
    DiscourseEvents::Calendar::Extractor.destroy(post)
    DiscourseEvents::Calendar::Event.where(post_id: post.id).destroy_all
  end

  validate(:post, :validate_calendar) do |force = nil|
    return unless raw_changed? || force

    validator = DiscourseEvents::Calendar::Validator.new(self)
    validator.validate_calendar
  end

  validate(:post, :validate_event) do |force = nil|
    return unless raw_changed? || force
    return if is_first_post?

    # Skip if not a calendar topic
    return if !topic&.first_post&.custom_fields&.[](DiscourseEvents::CALENDAR_CUSTOM_FIELD)

    validator = DiscourseEvents::Calendar::EventValidator.new(self)
    validator.validate_event
  end

  add_to_class(:post, :has_group_timezones?) do
    custom_fields[DiscourseEvents::GROUP_TIMEZONES_CUSTOM_FIELD].present?
  end

  add_to_class(:post, :group_timezones) do
    custom_fields[DiscourseEvents::GROUP_TIMEZONES_CUSTOM_FIELD] || {}
  end

  add_to_class(:post, :group_timezones=) do |val|
    if val.present?
      custom_fields[DiscourseEvents::GROUP_TIMEZONES_CUSTOM_FIELD] = val
    else
      custom_fields.delete(DiscourseEvents::GROUP_TIMEZONES_CUSTOM_FIELD)
    end
  end

  add_to_serializer(:post, :calendar_details, include_condition: -> { object.is_first_post? }) do
    start_date = 6.months.ago

    standalone_sql = <<~SQL
      SELECT post_number, description, start_date, end_date, username, recurrence, timezone
        FROM calendar_events
       WHERE topic_id = :topic_id
         AND post_id IS NOT NULL
       ORDER BY start_date, end_date
    SQL

    standalones =
      DB
        .query(standalone_sql, topic_id: object.topic_id)
        .map do |row|
          {
            type: :standalone,
            post_number: row.post_number,
            message: row.description,
            from: row.start_date,
            to: row.end_date,
            username: row.username,
            recurring: row.recurrence,
            post_url: Post.url("-", object.topic_id, row.post_number),
            timezone: row.timezone,
          }
        end

    timezones =
      UserOption
        .where(
          user_id:
            DiscourseEvents::Calendar::Event.where(
              topic_id: object.topic_id,
              post_id: nil,
              start_date: start_date..,
            ).select(:user_id),
        )
        .where("LENGTH(COALESCE(timezone, '')) > 0")
        .pluck(:user_id, :timezone)
        .to_h

    grouped = {}

    grouped_sql = <<~SQL
      SELECT region, start_date, timezone, user_id, username, description
        FROM calendar_events
       WHERE topic_id = :topic_id
         AND post_id IS NULL
         AND start_date >= :start_date
       ORDER BY region, start_date
    SQL

    DB
      .query(grouped_sql, topic_id: object.topic_id, start_date: start_date)
      .each do |row|
        identifier = "#{row.region.split("_").first}-#{row.start_date.strftime("%Y-%j")}"

        grouped[identifier] ||= {
          type: :grouped,
          from: row.start_date,
          timezone: row.timezone,
          name: [],
          users: [],
        }

        grouped[identifier][:name] << row.description
        grouped[identifier][:users] << { username: row.username, timezone: timezones[row.user_id] }
      end

    grouped.each do |_, v|
      v[:name].uniq!
      v[:name].sort!
      v[:name] = v[:name].join(", ")
      v[:users].uniq! { |u| u[:username] }
      v[:users].sort! { |a, b| a[:username] <=> b[:username] }
    end

    standalones + grouped.values
  end

  add_to_serializer(
    :post,
    :group_timezones,
    include_condition: -> do
      post_custom_fields[DiscourseEvents::GROUP_TIMEZONES_CUSTOM_FIELD].present?
    end,
  ) do
    result = {}
    group_timezones = post_custom_fields[DiscourseEvents::GROUP_TIMEZONES_CUSTOM_FIELD] || {}
    group_names = group_timezones["groups"] || []

    if group_names.present?
      visible_group_ids =
        Group
          .where(name: group_names)
          .visible_groups(scope.user)
          .members_visible_groups(scope.user)
          .select(:id)

      users =
        User
          .human_users
          .joins(:groups, :user_option)
          .where(groups: { id: visible_group_ids })
          .select("users.*", "groups.name AS group_name", "user_options.timezone")

      usernames_on_holiday = DiscourseEvents.users_on_holiday

      users.each do |u|
        result[u.group_name] ||= []
        result[u.group_name] << DiscourseEvents::GroupTimezones::UserSerializer.new(
          u,
          root: false,
          on_holiday: usernames_on_holiday&.include?(u.username),
        ).as_json
      end
    end

    result
  end

  add_to_serializer(:site, :users_on_holiday, include_condition: -> { scope.is_staff? }) do
    DiscourseEvents.users_on_holiday
  end

  on(:reduce_cooked) do |fragment, post|
    if SiteSetting.discourse_post_event_enabled
      fragment
        .css(".discourse-post-event")
        .each do |event_node|
          event_node.replace(DiscourseEvents::Events::EmailRenderer.render(event_node, post))
        rescue => e
          Discourse.warn_exception(
            e,
            message: "Failed to render event in email for post #{post&.id}",
          )
        end
    end
  end

  on(:reduce_excerpt) do |fragment, options|
    if SiteSetting.discourse_post_event_enabled
      DiscourseEvents::Events::Excerpt.call(fragment, post: options[:post])
    end
  end

  on(:user_destroyed) do |user|
    DiscourseEvents::Events::Invitee.where(user_id: user.id).destroy_all
    DiscourseEvents::Events::EventHost.where(user_id: user.id).delete_all
  end

  on(:user_removed_from_group) do |user, group|
    DiscourseEvents::Events::Event
      .where(id: DiscourseEvents::Events::Invitee.unscoped.where(user_id: user.id).select(:post_id))
      .where(status: DiscourseEvents::Events::Event.statuses[:private])
      .where("? = ANY(discourse_post_event_events.raw_invitees)", group.name)
      .find_each(&:enforce_private_invitees!)
  end

  add_post_revision_notifier_recipients do |post_revision|
    # next if no modifications
    next if !post_revision.modifications.present?

    # do no notify recipients when only updating tags
    next if post_revision.modifications.keys == ["tags"]

    ids = []
    post = post_revision.post

    if post && post.is_first_post? && post.event
      ids.concat(post.event.on_going_event_invitees.pluck(:user_id))
    end

    ids
  end

  on(:site_setting_changed) do |name, old_val, new_val|
    next if name != :discourse_post_event_allowed_custom_fields

    previous_fields = old_val.split("|")
    new_fields = new_val.split("|")
    removed_fields = previous_fields - new_fields

    next if removed_fields.empty?

    DiscourseEvents::Events::Event.all.find_each do |event|
      removed_fields.each { |field| event.custom_fields.delete(field) }
      event.save
    end
  end

  if defined?(DiscourseAutomation)
    on(:discourse_post_event_event_started) do |event|
      DiscourseAutomation::Automation
        .where(enabled: true, trigger: "event_started")
        .each do |automation|
          fields = automation.serialized_fields
          topic_id = fields.dig("topic_id", "value")

          next unless event.post.topic.id.to_s == topic_id

          automation.trigger!(
            "kind" => "event_started",
            "event" => event,
            "placeholders" => {
              "event_url" => event.url,
            },
          )
        end
    end

    add_triggerable_to_scriptable("event_started", "send_chat_message")

    add_automation_triggerable("event_started") do
      placeholder :event_url

      field :topic_id, component: :text
    end
  end

  query =
    Proc.new do |notifications, data|
      notifications.where("data::json ->> 'topic_title' = ?", data[:topic_title].to_s).where(
        "data::json ->> 'message' = ?",
        data[:message].to_s,
      )
    end

  reminders_consolidation_plan =
    Notifications::DeletePreviousNotifications.new(
      type: Notification.types[:event_reminder],
      previous_query_blk: query,
    )

  invitation_consolidation_plan =
    Notifications::DeletePreviousNotifications.new(
      type: Notification.types[:event_invitation],
      previous_query_blk: query,
    )

  register_notification_consolidation_plan(reminders_consolidation_plan)
  register_notification_consolidation_plan(invitation_consolidation_plan)

  Report.add_report("currently_away") do |report|
    group_filter = report.filters.dig(:group) || Group::AUTO_GROUPS[:staff]
    report.add_filter("group", type: "group", default: group_filter)

    break unless group = Group.find_by(id: group_filter)

    report.labels = [
      { property: :username, title: I18n.t("reports.currently_away.labels.username") },
    ]

    group_usernames = group.users.pluck(:username)
    on_holiday_usernames = DiscourseEvents.users_on_holiday
    report.data = (group_usernames & on_holiday_usernames).map { |username| { username: username } }
    report.total = report.data.count
  end

  register_anonymous_action("rsvp_event") do |user, params|
    event_id = params["event_id"]
    recurring = ActiveModel::Type::Boolean.new.cast(params["recurring"])
    existing_invitee = DiscourseEvents::Events::Invitee.find_by(post_id: event_id, user_id: user.id)

    if existing_invitee
      DiscourseEvents::Events::UpdateInvitee.call(
        params: {
          event_id: event_id,
          invitee_id: existing_invitee.id,
          status: params["status"],
          recurring: recurring,
        },
        guardian: user.guardian,
      )
    else
      DiscourseEvents::Events::CreateInvitee.call(
        params: {
          event_id: event_id,
          status: params["status"],
          recurring: recurring,
          user_id: user.id,
        },
        guardian: user.guardian,
      )
    end
  end

  # DISCOURSE LIVESTREAM

  add_to_serializer(
    :topic_view,
    :chat_channel_id,
    include_condition: -> do
      event = object.topic.first_post&.event
      event&.livestream? && object.topic.topic_chat_channel.present? &&
        (scope.is_admin? || event.can_access_livestream_chat?(scope.user))
    end,
  ) { object.topic.topic_chat_channel.chat_channel_id }

  add_to_serializer(:topic_view, :has_livestream) { object.topic.first_post&.event&.livestream? }

  add_to_serializer(
    :topic_view,
    :event_watching_invitee_status,
    include_condition: -> { scope.user.present? && object.topic.first_post&.event.present? },
  ) do
    invitee =
      DiscourseEvents::Events::Invitee.find_by(
        post_id: object.topic.first_post.event.id,
        user_id: scope.user.id,
      )

    DiscourseEvents::Events::Invitee.statuses[invitee.status] if invitee
  end

  reloadable_patch do
    Chat::ChannelSerializer.include(DiscourseEvents::Livestream::ChannelSerializerExtension)
  end

  register_modifier(:chat_channel_fetcher_public_includes) do |includes|
    includes + [{ livestream_topic_chat_channel: { topic: { first_post: :event } } }]
  end

  register_modifier(:chat_channel_serializer_public_options) do |serializer_options, guardian|
    serializer_options.merge(
      livestream_context:
        DiscourseEvents::Livestream::ChannelSerializationContext.new(guardian.user),
    )
  end

  add_to_serializer(
    "Chat::Channel",
    :livestream_topic,
    include_condition: -> do
      return false if object.chatable_type != "Category"

      topic = object.livestream_topic_chat_channel&.topic
      event = topic&.first_post&.event

      return false if !event

      event.livestream? &&
        event.can_access_livestream_chat?(scope.user, group_names: livestream_user_group_names)
    end,
  ) do
    topic = object.livestream_topic_chat_channel.topic
    event = topic.first_post&.event
    watching_invitee = livestream_invitees_by_post_id[event&.id]

    can_update_attendance =
      if scope.anonymous? || !event
        false
      else
        event.can_user_update_attendance?(scope.user, group_names: livestream_user_group_names)
      end

    {
      id: topic.id,
      title: topic.title,
      slug: topic.slug,
      url: topic.relative_url,
      event_id: topic.first_post&.id,
      reference_message_id: object.livestream_topic_chat_channel.reference_message_id,
      can_update_attendance: can_update_attendance,
      watching_invitee_status:
        watching_invitee && DiscourseEvents::Events::Invitee.statuses[watching_invitee.status],
    }
  end

  on(:chat_channel_trashed) do |channel, user|
    # If the chat channel is deleted, delete the related TopicChatChannel record
    DiscourseEvents::Livestream::TopicChatChannel.where(chat_channel_id: channel.id).destroy_all
  end

  on(:discourse_calendar_post_event_invitee_status_changed) do |invitee|
    # Withdrawing leaves no attendance to sync, and the record is already gone.
    next if invitee.destroyed?

    topic = invitee.event.post.topic
    topic_chat_channel = topic.topic_chat_channel

    next if !topic_chat_channel

    user = User.find(invitee.user_id)
    channel = topic_chat_channel.chat_channel
    manager = Chat::ChannelMembershipManager.new(channel)

    # Attendance is the chat gate: anyone going is auto-followed into the
    # livestream channel, anyone else is unfollowed.
    membership =
      if invitee.status == DiscourseEvents::Events::Invitee.statuses[:going]
        manager.follow(user)
      else
        manager.unfollow(user)
      end

    DiscourseEvents::Livestream.publish_livestream_chat_status(membership, user: user) if membership
  end
end

after_initialize do
  require_relative "lib/discourse_events/mcp_tools"
  register_mcp_tool(
    "discourse_calendar_event_list",
    title: "List events",
    description: "Lists upcoming events whose posts are visible to the authenticated user.",
    implementation: DiscourseEvents::McpTools::ListEvents,
    input_schema: {
      type: "object",
      properties: {
        limit: {
          type: "integer",
          minimum: 1,
          maximum: 100,
        },
      },
      additionalProperties: false,
    },
    required_scopes: %w[discourse-calendar:read],
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
    },
    availability: -> do
      SiteSetting.discourse_events_enabled && SiteSetting.discourse_post_event_enabled
    end,
  )
end
