class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0]
  RESOLUTION_GRACE = 1.second
  SECTION_LITERAL_PATTERN = /\[[^\[\]\s]*-[^\[\]\s]*\]\z/

  class MigrationPostVersion < ActiveRecord::Base
    self.table_name = 'post_versions'
  end

  class MigrationTag < ActiveRecord::Base
    self.table_name = 'tags'
  end

  class MigrationTagName < ActiveRecord::Base
    self.table_name = 'tag_names'
  end

  class MigrationTagVersion < ActiveRecord::Base
    self.table_name = 'tag_versions'
  end

  class MigrationNicoTagVersion < ActiveRecord::Base
    self.table_name = 'nico_tag_versions'
  end

  def up
    add_column :post_versions, :tags_json, :json, after: :tags
    MigrationPostVersion.reset_column_information

    backfill_missing_initial_tag_versions!
    intervals_by_name = build_intervals_by_name

    say_with_time 'Backfilling post_versions.tags_json' do
      MigrationPostVersion.where(tags_json: nil).find_each(batch_size: 500) do |version|
        version.update_columns(tags_json: build_tags_json(version, intervals_by_name))
      end
    end

    change_column_null :post_versions, :tags_json, false

    schema = connection.quote(JSON.generate({
        type: 'array',
        items: { type: 'object',
                 properties: { id: { type: 'integer', minimum: 1 },
                               version_no: { type: 'integer', minimum: 1 },
                               name: { type: 'string', minLength: 1 },
                               category: { type: 'string', enum: ['deerjikist',
                                                                  'meme',
                                                                  'character',
                                                                  'general',
                                                                  'material',
                                                                  'meta',
                                                                  'nico'] },
                               sections: { type: 'array',
                                           items: { type: 'object',
                                                    properties: { begin_ms: { type: 'integer',
                                                                              minimum: 0 },
                                                                  end_ms: { type: ['integer',
                                                                                   'null'],
                                                                            minimum: 0 } },
                                                    required: ['begin_ms', 'end_ms'],
                                                    additionalProperties: false } } },
                 required: ['id', 'version_no', 'name', 'category', 'sections'],
                 additionalProperties: false } }))

    add_check_constraint :post_versions,
                         "JSON_SCHEMA_VALID(#{ schema }, tags_json)",
                         name: 'chk_post_versions_tags_json_schema'
  end

  def down
    remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema'

    remove_column :post_versions, :tags_json
  end

  private

  def backfill_missing_initial_tag_versions!
    say_with_time 'Backfilling missing initial tag versions' do
      tag_rows = missing_initial_version_rows(MigrationTagVersion, nico: false)
      nico_rows = missing_initial_version_rows(MigrationNicoTagVersion, nico: true)

      MigrationTagVersion.insert_all!(tag_rows) if tag_rows.any?
      MigrationNicoTagVersion.insert_all!(nico_rows) if nico_rows.any?

      tag_rows.length + nico_rows.length
    end
  end

  def missing_initial_version_rows version_class, nico:
    first_versions =
        version_class
        .order(:tag_id, :version_no)
        .to_a
        .group_by(&:tag_id)
        .transform_values(&:first)
    rows = []

    MigrationTag.find_each do |tag|
      next if (tag.category == 'nico') != nico

      first_version = first_versions[tag.id]
      next unless first_version
      next if valid_initial_version?(first_version)

      assert_inferable_initial_version!(tag, first_version)
      rows << initial_version_row(tag, first_version, nico:)
    end

    rows
  end

  def valid_initial_version? version
    version.version_no == 1 && version.event_type == 'create'
  end

  def assert_inferable_initial_version! tag, version
    inferable =
        version.version_no == 2 &&
        version.event_type == 'discard' &&
        tag.created_at < version.created_at
    return if inferable

    details = [
        "tag_id=#{ tag.id }",
        "version_no=#{ version.version_no }",
        "event_type=#{ version.event_type.inspect }",
        "tag_created_at=#{ tag.created_at.iso8601(6) }",
        "version_created_at=#{ version.created_at.iso8601(6) }"]

    raise "Cannot infer initial tag version: #{ details.join(', ') }"
  end

  def initial_version_row tag, discard_version, nico:
    row = {
        tag_id: tag.id,
        version_no: 1,
        event_type: 'create',
        name: discard_version.name,
        created_at: tag.created_at,
        created_by_user_id: nil }

    if nico
      return row.merge(linked_tags: discard_version.linked_tags)
    end

    row.merge(
        category: discard_version.category,
        aliases: discard_version.aliases,
        parent_tag_ids: discard_version.parent_tag_ids,
        deprecated_at: discard_version.deprecated_at)
  end

  def build_intervals_by_name
    intervals_by_name = Hash.new { |hash, name| hash[name] = [] }
    versions_by_kind = {
        tag: versions_by_tag_id(MigrationTagVersion),
        nico: versions_by_tag_id(MigrationNicoTagVersion) }
    current_names = current_names_by_tag_id

    MigrationTag.find_each do |tag|
      nico = tag.category == 'nico'
      kind = nico ? :nico : :tag
      versions = versions_by_kind.fetch(kind).fetch(tag.id, [])

      intervals_for(
          tag,
          versions,
          current_name: current_names.fetch(tag.id),
          nico:).each do |interval|
        name = interval.delete(:name)
        intervals_by_name[name] << interval
      end
    end

    intervals_by_name
  end

  def versions_by_tag_id version_class
    version_class
        .order(:tag_id, :version_no)
        .to_a
        .group_by(&:tag_id)
  end

  def current_names_by_tag_id
    MigrationTagName
        .joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
        .pluck('tags.id', 'tag_names.name')
        .to_h
  end

  def intervals_for tag, versions, current_name:, nico:
    if versions.empty?
      return [{
          name: current_name,
          tag_id: tag.id,
          version_no: tag.version_no,
          category: nico ? 'nico' : tag.category,
          from: tag.created_at,
          to: tag.discarded_at }]
    end

    versions.each_with_index.filter_map do |version, index|
      next if version.event_type == 'discard'

      {
          name: version.name,
          tag_id: tag.id,
          version_no: version.version_no,
          category: nico ? 'nico' : version.category,
          from: version.created_at,
          to: versions[index + 1]&.created_at || tag.discarded_at }
    end
  end

  def build_tags_json version, intervals_by_name
    entries = version.tags.to_s.split.map do |literal|
      name = tag_name_from_literal(literal)
      interval = resolve_tag!(intervals_by_name.fetch(name, []), name:, version:)

      { 'id' => interval.fetch(:tag_id),
        'version_no' => interval.fetch(:version_no),
        'name' => name,
        'category' => interval.fetch(:category),
        'sections' => [] }
    end

    assert_unique_tag_ids!(version, entries)

    entries.sort_by { |entry| entry.fetch('id') }
  end

  def tag_name_from_literal literal
    name = literal.dup
    name.sub!(SECTION_LITERAL_PATTERN, '') while name.match?(
        SECTION_LITERAL_PATTERN)

    if name.empty? || name.include?('[') || name.include?(']')
      raise "Invalid legacy tag literal: #{ literal.inspect }"
    end

    name
  end

  def resolve_tag! intervals, name:, version:
    time = version.created_at
    candidates = intervals.select do |interval|
      interval.fetch(:from) <= time &&
        (interval[:to].nil? || time < interval.fetch(:to))
    end

    candidates = future_candidates(intervals, time) if candidates.empty?

    return candidates.first if candidates.one?

    candidate_versions = candidates.map do |candidate|
      [candidate.fetch(:tag_id), candidate.fetch(:version_no)]
    end
    details = [
        "post_version_id=#{ version.id }",
        "post_id=#{ version.post_id }",
        "name=#{ name.inspect }",
        "created_at=#{ time.iso8601(6) }",
        "candidates=#{ candidate_versions.inspect }"].join(', ')

    raise "Could not resolve tag snapshot: #{ details }"
  end

  def future_candidates intervals, time
    candidates = intervals.select do |interval|
      interval.fetch(:from) > time &&
        interval.fetch(:from) <= time + RESOLUTION_GRACE
    end
    return [] if candidates.empty?

    nearest_from = candidates.map { |interval| interval.fetch(:from) }.min

    candidates.select do |interval|
      interval.fetch(:from) == nearest_from
    end
  end

  def assert_unique_tag_ids! version, entries
    duplicate_tag_ids =
        entries
        .map { |entry| entry.fetch('id') }
        .tally
        .select { |_tag_id, count| count > 1 }
        .keys
    return if duplicate_tag_ids.empty?

    details = [
        "post_version_id=#{ version.id }",
        "duplicate_tag_ids=#{ duplicate_tag_ids.inspect }"].join(', ')

    raise "Duplicate tag IDs: #{ details }"
  end
end
