class PostsController < ApplicationController
  Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
  MAX_BULK_REQUEST_BYTES = 40 * 1024 * 1024

  class VideoMsParseError < ArgumentError
    ;
  end

  def index
    url = params[:url].presence
    title = params[:title].presence
    original_created_from = params[:original_created_from].presence
    original_created_to = params[:original_created_to].presence
    created_between = params[:created_from].presence, params[:created_to].presence
    updated_between = params[:updated_from].presence, params[:updated_to].presence

    order = params[:order].to_s.split(':', 2).map(&:strip)
    unless order[0].in?(['title', 'url', 'original_created_at', 'created_at', 'updated_at'])
      order[0] = 'original_created_at'
    end
    unless order[1].in?(['asc', 'desc'])
      order[1] =
          if order[0].in?(['title', 'url'])
            'asc'
          else
            'desc'
          end
    end

    page = (params[:page].presence || 1).to_i
    limit = (params[:limit].presence || 20).to_i

    page = 1 if page < 1
    limit = 1 if limit < 1

    offset = (page - 1) * limit

    pt_max_sql =
        PostTag
        .select('post_id, MAX(updated_at) AS max_updated_at')
        .group('post_id')
        .to_sql

    updated_at_all_sql =
        'GREATEST(posts.updated_at,' +
        'COALESCE(pt_max.max_updated_at, posts.updated_at))'

    q =
        filtered_posts
        .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
        .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
        .preload(:uploaded_user, :parents, :children,
                 active_post_tags: [:sections,
                                    { tag: [:deerjikists, :materials,
                                            { tag_name: :wiki_page }] }])
        .with_attached_thumbnail

    q = q.where('posts.url LIKE ?', "%#{ url }%") if url
    q = q.where('posts.title LIKE ?', "%#{ title }%") if title
    if original_created_from
      q = q.where('posts.original_created_before > ?', original_created_from)
    end
    if original_created_to
      q = q.where('posts.original_created_from <= ?', original_created_to)
    end
    q = q.where('posts.created_at >= ?', created_between[0]) if created_between[0]
    q = q.where('posts.created_at <= ?', created_between[1]) if created_between[1]
    if updated_between[0]
      q = q.where("#{ updated_at_all_sql } >= ?", updated_between[0])
    end
    if updated_between[1]
      q = q.where("#{ updated_at_all_sql } <= ?", updated_between[1])
    end

    sort_sql =
        case order[0]
        when 'original_created_at'
          'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE,' +
          'posts.original_created_from,' +
          'posts.created_at) '
        when 'updated_at'
          updated_at_all_sql
        else
          "posts.#{ order[0] }"
        end
    posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, posts.id #{ order[1] }"))
             .limit(limit)
             .offset(offset)
             .to_a

    q = q.except(:select, :order)

    render json: { posts: posts.map { |post|
      PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json|
        json['thumbnail'] =
            if post.thumbnail.attached?
              rails_storage_proxy_url(post.thumbnail, only_path: false)
            else
              nil
            end
      end
    }, count: q.group_values.present? ? q.count.size : q.count }
  end

  def random
    post = filtered_posts.preload(:uploaded_user, :parents, :children,
                                  active_post_tags: [:sections,
                                                     { tag: [:deerjikists, :materials,
                                                             { tag_name: :wiki_page }] }])
                         .with_attached_thumbnail
                         .order('RAND()')
                         .first
    return head :not_found unless post

    render json: PostRepr.base(post, current_user)
  end

  def metadata
    return head :unauthorized unless current_user
    return head :forbidden unless current_user.gte_member?
    return render_bad_request('URL は必須です．') if params[:url].blank?

    normal_url = PostUrlNormaliser.normalise(params[:url].to_s)
    return render_validation_error(fields: { url: ['URL が不正です．'] }) if normal_url.blank?

    Preview::UrlSafety.validate(normal_url)
    existing_post = Post.with_attached_thumbnail.find_by(url: normal_url)
    if existing_post.present?
      return render json: {
          url: normal_url,
          title: nil,
          thumbnail_base: nil,
          tags: nil,
          display_tags: [],
          original_created_from: nil,
          original_created_before: nil,
          duration: nil,
          video_ms: nil,
          field_warnings: { },
          base_warnings: [],
          existing_post_id: existing_post.id,
          existing_post: compact_post(existing_post.id) }
    end

    metadata = PostMetadataFetcher.fetch(normal_url)
    field_warnings = { }
    field_warnings[:title] = ['タイトルを取得できませんでした．'] if metadata[:title].blank?
    if metadata[:thumbnail_base].blank?
      field_warnings[:thumbnail_base] = ['サムネールを取得できませんでした．']
    end

    render json: {
        url: normal_url,
        title: metadata[:title],
        thumbnail_base: metadata[:thumbnail_base],
        tags: metadata[:tags],
        display_tags: metadata[:display_tags],
        original_created_from: metadata[:original_created_from],
        original_created_before: metadata[:original_created_before],
        duration: metadata[:duration],
        video_ms: metadata[:video_ms],
        field_warnings: field_warnings,
        base_warnings: [],
        existing_post_id: nil,
        existing_post: nil }
  rescue ArgumentError => e
    render_bad_request e.message
  rescue Preview::UrlSafety::UnsafeUrl => e
    render_validation_error fields: { url: [e.message] }
  rescue Preview::HttpFetcher::FetchFailed,
         Preview::HttpFetcher::FetchTimeout,
         Preview::HttpFetcher::ResponseTooLarge
    render json: {
        url: normal_url,
        title: nil,
        thumbnail_base: nil,
        tags: nil,
        display_tags: [],
        original_created_from: nil,
        original_created_before: nil,
        duration: nil,
        video_ms: nil,
        field_warnings: { url: ['自動取得に失敗しました．'] },
        base_warnings: [],
        existing_post_id: nil,
        existing_post: nil }
  end

  def show
    post =
        Post
        .includes(:uploaded_user, :parents, :children,
                  active_post_tags: [:sections,
                                     { tag: [:deerjikists, :materials,
                                             { tag_name: :wiki_page }] }])
        .with_attached_thumbnail
        .find_by(id: params[:id])
    return head :not_found unless post

    parent_posts = post.parents.with_attached_thumbnail.order(:id).to_a
    child_posts = post.children.with_attached_thumbnail.order(:id).to_a
    sibling_posts = sibling_posts_by_parent(parent_posts.map(&:id))
    related = post.related(limit: 20).to_a

    render json: PostRepr.detail(post, current_user,
                                 parent_posts:,
                                 child_posts:,
                                 sibling_posts:,
                                 related:)
                         .merge(tags: build_tag_tree_for(post))
  end

  def create
    return head :unauthorized unless current_user
    return head :forbidden unless current_user.gte_member?

    preflight = PostCreatePreflight.new(
        attributes: post_create_attributes,
        thumbnail: params[:thumbnail],
        host: request.base_url).run
    return render json: dry_run_json(preflight) if bool?(:dry)
    if preflight[:existing_post_id].present?
      post = Post.new(url: preflight[:url])
      post.errors.add :url, :taken
      return render_post_form_record_invalid post
    end

    post = PostCreator.new(actor: current_user,
                           attributes: post_create_attributes.merge(
                               preflight.slice(
                                   :url,
                                   :title,
                                   :thumbnail_base,
                                   :tags,
                                   :parent_post_ids,
                                   :original_created_from,
                                   :original_created_before,
                                   :duration,
                                   :video_ms,
                                   :direct_tag_specs,
                                   :default_tag_specs,
                                   :snapshot_tag_specs,
                                   :post_tag_specs,
                                   :tag_sections,
                                   :normalised_parent_post_ids).symbolize_keys).merge(
                               thumbnail: params[:thumbnail])).create!

    post.reload
    render json: PostRepr.base(post), status: :created
  rescue PostCreatePreflight::ValidationFailed => e
    render_validation_error fields: e.fields, base: e.base_errors
  rescue Tag::NicoTagNormalisationError
    render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません．' }
  rescue Tag::DeprecatedTagNormalisationError
    render_unprocessable_entity '廃止済みタグは付与できません．', field: :tags
  rescue Tag::SectionLiteralParseError
    render_validation_error fields: { tags: ['タグ区間の記法が不正です．'] }
  rescue PostCreator::VideoMsParseError
    render_validation_error fields: { video_ms: ['動画時間の記法が不正です．'] }
  rescue Post::RemoteThumbnailFetchFailed
    render_validation_error fields: { thumbnail_base: ['サムネイル画像の取得に失敗しました．'] }
  rescue MiniMagick::Error
    render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました．'] }
  rescue ArgumentError => e
    render_validation_error fields: { parent_post_ids: [e.message] }
  rescue ActiveRecord::RecordInvalid => e
    render_post_form_record_invalid e.record
  end

  def bulk
    return head :unauthorized unless current_user
    return head :forbidden unless current_user.gte_member?
    return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form]
    return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
    posts = parse_bulk_posts_manifest
    thumbnails = parse_bulk_thumbnails(posts.length)
    result = PostBulkCreator.new(
        actor: current_user,
        posts:,
        thumbnails:,
        host: request.base_url).run
    render json: result
  rescue JSON::ParserError
    render_bad_request 'posts manifest の JSON が不正です．'
  rescue ArgumentError => e
    render_validation_error base: [e.message]
  end

  def viewed
    return head :unauthorized unless current_user

    current_user.viewed_posts << Post.find(params[:id])
    head :no_content
  end

  def unviewed
    return head :unauthorized unless current_user

    current_user.viewed_posts.delete(Post.find(params[:id]))
    head :no_content
  end

  def update
    return head :unauthorized unless current_user
    return head :forbidden unless current_user.gte_member?

    force = bool?(:force)
    merge = bool?(:merge)
    return render_bad_request('force と merge は同時に指定できません．') if force && merge

    base_version_no = parse_base_version_no
    return render_bad_request('base_version_no は必須です．') if !(force) && !(base_version_no)

    title = params[:title].presence
    tag_names = params[:tags].to_s.split
    original_created_from = params[:original_created_from]
    original_created_before = params[:original_created_before]
    parent_post_ids = parse_parent_post_ids

    post = nil
    conflict_json = nil

    ApplicationRecord.transaction do
      post = Post.lock.find(params[:id].to_i)

      base_version = nil
      base_snapshot = nil
      current_snapshot = nil
      unless force
        base_version = post.post_versions.find_by!(version_no: base_version_no)

        base_snapshot = post_snapshot_from_version(base_version)
        current_snapshot = post_snapshot_from_record(post)
      end
      incoming_snapshot = post_incoming_snapshot(title:,
                                                 original_created_from:,
                                                 original_created_before:,
                                                 tag_names:,
                                                 video_ms_param: params[:video_ms],
                                                 duration_param: params[:duration],
                                                 parent_post_ids:)

      snapshot_to_apply =
          if force || post.version_no == base_version_no || current_snapshot == base_snapshot
            incoming_snapshot
          else
            changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
            conflicts = changes.select { |change| change[:conflict] }

            if merge && conflicts.empty?
              merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot)
            else
              conflict_json = post_conflict_json(post:,
                                                 base_version_no:,
                                                 base_snapshot:,
                                                 current_snapshot:,
                                                 incoming_snapshot:,
                                                 changes:,
                                                 conflicts:)
              raise ActiveRecord::Rollback
            end
          end

      apply_post_snapshot!(post, snapshot_to_apply)
    end

    return render json: conflict_json, status: :conflict if conflict_json

    post.reload
    json = PostRepr.base(post, current_user)
    json['tags'] = build_tag_tree_for(post)
    render json:, status: :ok
  rescue Tag::NicoTagNormalisationError
    render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません．'] }
  rescue Tag::DeprecatedTagNormalisationError
    render_unprocessable_entity '廃止済みタグは付与できません．', field: :tags
  rescue Tag::SectionLiteralParseError
    render_validation_error fields: { tags: ['タグ区間の記法が不正です．'] }
  rescue VideoMsParseError
    render_validation_error fields: { video_ms: ['動画時間の記法が不正です．'] }
  rescue ArgumentError => e
    render_validation_error fields: { parent_post_ids: [e.message] }
  rescue ActiveRecord::RecordInvalid => e
    render_post_form_record_invalid e.record
  end

  def changes
    id = params[:id].presence
    tag_id = params[:tag].presence
    page = (params[:page].presence || 1).to_i
    limit = (params[:limit].presence || 20).to_i

    page = 1 if page < 1
    limit = 1 if limit < 1

    offset = (page - 1) * limit

    pts = PostTag.with_discarded
    pts = pts.where(post_id: id) if id.present?
    pts = pts.where(tag_id:) if tag_id.present?
    pts = pts.includes(:post, :created_user, :deleted_user,
                       tag: [:deerjikists, :materials, { tag_name: :wiki_page }])

    events = []
    pts.each do |pt|
      tag = TagRepr.base(pt.tag)
      post = pt.post

      events << Event.new(
          post:,
          tag:,
          user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
          change_type: 'add',
          timestamp: pt.created_at)

      if pt.discarded_at
        events << Event.new(
            post:,
            tag:,
            user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
            change_type: 'remove',
            timestamp: pt.discarded_at)
      end
    end
    events.sort_by!(&:timestamp)
    events.reverse!

    render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
  end

  private

  def filtered_posts
    tag_names = params[:tags].to_s.split
    match_type = params[:match]
    if tag_names.present?
      filter_posts_by_tags(tag_names, match_type)
    else
      Post.all
    end
  end

  def filter_posts_by_tags tag_names, match_type
    literals = tag_names.map do |raw_name|
      { name: TagName.canonicalise(raw_name.sub(/\Anot:/i, '')).first,
        negative: raw_name.downcase.start_with?('not:') }
    end

    return Post.all if literals.empty?

    if match_type == 'any'
      literals.reduce(Post.none) do |posts, literal|
        posts.or(tag_literal_relation(literal[:name], negative: literal[:negative]))
      end
    else
      literals.reduce(Post.all) do |posts, literal|
        ids = tagged_post_ids_for(literal[:name])
        if literal[:negative]
          posts.where.not(id: ids)
        else
          posts.where(id: ids)
        end
      end
    end
  end

  def tag_literal_relation name, negative:
    ids = tagged_post_ids_for(name)
    if negative
      Post.where.not(id: ids)
    else
      Post.where(id: ids)
    end
  end

  def tagged_post_ids_for(name) =
      Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)

  def sync_post_tags! post, desired_tags, sections
    desired_tags.each do |t|
      t.save! if t.new_record?
    end

    desired_ids = desired_tags.map(&:id).to_set
    current_ids = post.tags.pluck(:id).to_set

    to_add = desired_ids - current_ids
    to_remove = current_ids - desired_ids

    Tag.where(id: to_add).find_each do |tag|
      begin
        PostTag.create!(post:, tag:, created_user: current_user)
      rescue ActiveRecord::RecordNotUnique
        ;
      end
    end

    PostTagSection.where(post_id: post.id).destroy_all
    sections.each do |tag_id, ranges|
      ranges.each do |begin_ms, end_ms|
        PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:)
      end
    end

    PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
      pt.discard_by!(current_user)
    end
  end

  def build_tag_tree_for post
    post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
    tags = post_tags.map(&:tag)
    tag_ids = tags.map(&:id)

    implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)

    children_ids_by_parent = Hash.new { |h, k| h[k] = [] }
    implications.each do |imp|
      children_ids_by_parent[imp.parent_tag_id] << imp.tag_id
    end

    child_ids = children_ids_by_parent.values.flatten.uniq

    root_ids = tag_ids - child_ids

    tags_by_id = tags.index_by(&:id)
    sections_by_tag_id = post_tags.to_h { |post_tag|
      [post_tag.tag_id, post_tag.sections.as_json(only: [:begin_ms, :end_ms])]
    }

    memo = { }

    build_node = -> tag_id, path do
      tag = tags_by_id[tag_id]
      return nil unless tag

      sections = sections_by_tag_id.fetch(tag_id, [])

      if path.include?(tag_id)
        return TagRepr.inline(tag).merge(children: [], sections:)
      end

      if memo.key?(tag_id)
        return memo[tag_id]
      end

      new_path = path + [tag_id]
      child_ids = children_ids_by_parent[tag_id] || []

      children = child_ids.filter_map { |cid| build_node.(cid, new_path) }

      memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
    end

    root_ids.filter_map { |id| build_node.call(id, []) }
  end

  def sibling_posts_by_parent parent_post_ids
    return { } if parent_post_ids.blank?

    implications =
        PostImplication
        .where(parent_post_id: parent_post_ids)
        .includes(post: { thumbnail_attachment: :blob })
        .order(:parent_post_id, :post_id)

    implications.group_by(&:parent_post_id).transform_values { |items|
      items.map(&:post)
    }
  end

  def parse_parent_post_ids
    raise ArgumentError, 'parent_post_ids は必須です．' unless params.key?(:parent_post_ids)

    params[:parent_post_ids].to_s.split.map { |token|
      id = Integer(token, exception: false)
      raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0

      id
    }.uniq
  end

  def post_create_attributes
    { title: params[:title],
      url: params[:url],
      thumbnail_base: params[:thumbnail_base],
      tags: params[:tags],
      original_created_from: params[:original_created_from],
      original_created_before: params[:original_created_before],
      parent_post_ids: parse_parent_post_ids,
      video_ms: params[:video_ms],
      duration: params[:duration] }
  end

  def parse_bulk_posts_manifest
    manifest = params[:posts]
    raise ArgumentError, 'posts は必須です．' if manifest.blank?
    raise ArgumentError, 'posts は JSON 文字列で指定してください．' unless manifest.is_a?(String)

    posts = JSON.parse(manifest)
    raise ArgumentError, 'posts は配列で指定してください．' unless posts.is_a?(Array)
    raise ArgumentError, '投稿件数は 1 件以上必要です．' if posts.empty?
    raise ArgumentError, '投稿件数が多すぎます．' if posts.length > 100
    raise ArgumentError, 'posts 要素の形式が不正です．' unless posts.all? { _1.is_a?(Hash) }

    posts
  end

  def parse_bulk_thumbnails post_count
    thumbnails = { }
    raw = params[:thumbnails]
    return thumbnails if raw.blank?
    raise ArgumentError, 'thumbnail key が不正です．' unless raw.respond_to?(:to_unsafe_h)

    raw.to_unsafe_h.each do |key, value|
      raise ArgumentError, 'thumbnail key が不正です．' unless key.to_s.match?(/\A\d+\z/)

      index = Integer(key, 10)
      raise ArgumentError, 'thumbnail index が範囲外です．' if index.negative? || index >= post_count
      raise ArgumentError, 'thumbnail index が重複しています．' if thumbnails.key?(index)
      unless value.is_a?(ActionDispatch::Http::UploadedFile)
        raise ArgumentError, 'thumbnail upload が不正です．'
      end

      thumbnails[index] = value
    end

    thumbnails
  end

  def compact_post post_id
    return nil if post_id.blank?

    post = Post.with_attached_thumbnail.find_by(id: post_id)
    PostCompactRepr.base(post, host: request.base_url)
  end

  def dry_run_json preflight
    preflight.slice(
        :url,
        :title,
        :thumbnail_base,
        :tags,
        :display_tags,
        :parent_post_ids,
        :original_created_from,
        :original_created_before,
        :duration,
        :video_ms,
        :field_warnings,
        :base_warnings,
        :existing_post_id,
        :existing_post)
  end

  def sync_parent_posts! post, parent_post_ids
    if parent_post_ids.include?(post.id)
      post.errors.add :parent_post_ids, '自分自身を親投稿にはできません．'
      raise ActiveRecord::RecordInvalid, post
    end

    existing_ids = Post.where(id: parent_post_ids).pluck(:id)
    missing_ids = parent_post_ids - existing_ids

    if missing_ids.present?
      post.errors.add :parent_post_ids,
                      "存在しない親投稿 Id. があります: #{ missing_ids.join(' ') }"
      raise ActiveRecord::RecordInvalid, post
    end

    current_ids = post.parent_posts.pluck(:id)

    ids_to_add = parent_post_ids - current_ids
    ids_to_remove = current_ids - parent_post_ids

    PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all

    ids_to_add.each do |parent_post_id|
      PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:)
    end
  end

  def parse_base_version_no
    version_no = Integer(params[:base_version_no], exception: false)
    if version_no&.positive?
      version_no
    else
      nil
    end
  end

  def post_snapshot_from_version version
    { title: version.title,
      video_ms: version.respond_to?(:video_ms) ? version.video_ms : nil,
      original_created_from: snapshot_time(version.original_created_from),
      original_created_before: snapshot_time(version.original_created_before),
      tag_names: editable_tag_names_from_version(version),
      parent_post_ids: snapshot_parent_post_ids_from_version(version) }
  end

  def editable_tag_names_from_version version
    version.tags_json
        .reject { _1.fetch('category') == 'nico' }
        .map { Post.tag_snapshot_literal(_1) }
        .sort
  end

  def post_snapshot_from_record post
    { title: post.title,
      video_ms: post.video_ms,
      original_created_from: snapshot_time(post.original_created_from),
      original_created_before: snapshot_time(post.original_created_before),
      tag_names: editable_tag_names_from_post(post),
      parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
  end

  def editable_tag_names_from_post post
    post
        .post_tags
        .kept
        .joins(tag: :tag_name)
        .merge(Tag.not_nico)
        .merge(Tag.where(deprecated_at: nil))
        .includes(:sections, tag: :tag_name)
        .order('tag_names.name')
        .map do |post_tag|
      name = post_tag.tag.tag_name.name
      sections = post_tag.sections.sort_by(&:begin_ms)

      next name if sections.empty?

      "#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
    end
  end

  def post_incoming_snapshot title:, original_created_from:, original_created_before:,
                             tag_names:, video_ms_param:, duration_param:, parent_post_ids:
    validate_original_created_values!(original_created_from, original_created_before)
    Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
                                        with_sections: true) =>
        { tags:, sections: }

    tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
    video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
    validate_video_sections!(video_ms, sections)

    { title:,
      video_ms:,
      original_created_from: snapshot_time(original_created_from),
      original_created_before: snapshot_time(original_created_before),
      tag_names: tags.uniq(&:id).map { |tag|
        "#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
      }.sort,
      parent_post_ids: parent_post_ids.sort }
  end

  def snapshot_parent_post_ids_from_version version
    if version.respond_to?(:parent_post_ids)
      version.parent_post_ids.to_s.split.map { |id| id.to_i }.sort
    elsif version.respond_to?(:parent_id) && version.parent_id
      [version.parent_id]
    else
      []
    end
  end

  def snapshot_time value
    return nil if value.blank?

    value = Time.zone.parse(value.to_s) if value in String
    value&.in_time_zone&.iso8601(6)
  rescue ArgumentError, TypeError
    value.to_s
  end

  def validate_original_created_values! original_created_from, original_created_before
    candidate = Post.new(
        url: 'https://example.invalid/original-created-validation',
        original_created_from:,
        original_created_before:)
    candidate.valid?
    fields = [:original_created_from, :original_created_before, :original_created_at]
    relevant_errors = candidate.errors.select { fields.include?(_1.attribute) }
    return if relevant_errors.empty?

    invalid_post = Post.new
    relevant_errors.each { |error|
      invalid_post.errors.add(error.attribute, error.message)
    }
    raise ActiveRecord::RecordInvalid, invalid_post
  end

  def section_literal section
    "[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
  end

  def post_conflict_json post:, base_version_no:, base_snapshot:,
                         current_snapshot:, incoming_snapshot:, changes:, conflicts:
    { error: 'conflict',
      message: '競合が発生しました．',
      post_id: post.id,
      base_version_no:,
      current_version_no: post.version_no,
      base: base_snapshot,
      current: current_snapshot,
      mine: incoming_snapshot,
      changes:,
      conflicts:,
      mergeable: conflicts.empty? }
  end

  def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
    [scalar_snapshot_change(:title, 'タイトル',
                            base_snapshot, current_snapshot, incoming_snapshot),
     scalar_snapshot_change(:video_ms, '動画時間',
                            base_snapshot, current_snapshot, incoming_snapshot),
     scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時（以降）',
                            base_snapshot, current_snapshot, incoming_snapshot),
     scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時（より前）',
                            base_snapshot, current_snapshot, incoming_snapshot),
     set_snapshot_change(:tag_names, 'タグ',
                         base_snapshot, current_snapshot, incoming_snapshot),
     set_snapshot_change(:parent_post_ids, '親投稿',
                         base_snapshot, current_snapshot, incoming_snapshot)].compact
  end

  def scalar_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
    base = base_snapshot[field]
    current = current_snapshot[field]
    mine = incoming_snapshot[field]

    return nil if current == base && mine == base

    { field:, label:, base:, current:, mine:,
      changed_by_current: current != base,
      changed_by_me: mine != base,
      conflict: scalar_snapshot_conflict?(base, current, mine) }
  end

  def scalar_snapshot_conflict? base, current, mine
    current != base && mine != base && current != mine
  end

  def set_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot
    base = base_snapshot[field].to_a
    current = current_snapshot[field].to_a
    mine = incoming_snapshot[field].to_a

    added_by_current = current - base
    removed_by_current = base - current
    added_by_me = mine - base
    removed_by_me = base - mine

    if (added_by_current.empty? &&
        removed_by_current.empty? &&
        added_by_me.empty? &&
        removed_by_me.empty?)
      return nil
    end

    { field:, label:, base:, current:, mine:, added_by_current:, removed_by_current:,
      added_by_me:, removed_by_me:,
      changed_by_current: added_by_current.present? || removed_by_current.present?,
      changed_by_me: added_by_me.present? || removed_by_me.present?,
      conflict: set_snapshot_conflict?(added_by_current:,
                                       removed_by_current:,
                                       added_by_me:,
                                       removed_by_me:) }
  end

  def set_snapshot_conflict? added_by_current:, removed_by_current:,
                             added_by_me:, removed_by_me:
    (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
  end

  def apply_post_snapshot! post, snapshot
    PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)

    post.update!(title: snapshot[:title],
                 video_ms: snapshot[:video_ms],
                 original_created_from: snapshot[:original_created_from],
                 original_created_before: snapshot[:original_created_before])

    Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
                                               deny_deprecated: true,
                                               with_sections: true) =>
        { tags: editable_tags, sections: }
    TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)

    readonly_tags = post.tags.nico.to_a

    tags = readonly_tags + editable_tags
    tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)

    post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
    validate_video_sections!(post.video_ms, sections)
    post.save!
    sync_post_tags!(post, tags, sections)
    sync_parent_posts!(post, snapshot[:parent_post_ids])

    PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
  end

  def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
    [:title, :video_ms, :original_created_from, :original_created_before].map {
      [_1, merge_scalar_snapshot_value(base_snapshot[_1],
                                       current_snapshot[_1],
                                       incoming_snapshot[_1])]
    }.to_h.merge([:tag_names, :parent_post_ids].map {
      [_1, merge_set_snapshot_value(base_snapshot[_1],
                                    current_snapshot[_1],
                                    incoming_snapshot[_1])]
    }.to_h)
  end

  def merge_scalar_snapshot_value base, current, mine
    return mine if current == base
    return current if mine == base || current == mine

    raise ArgumentError, '競合してゐる項目はマージできません．'
  end

  def merge_set_snapshot_value base, current, mine
    base = base.to_a
    current = current.to_a
    mine = mine.to_a

    added_by_current = current - base
    removed_by_current = base - current
    added_by_me = mine - base
    removed_by_me = base - mine

    merged = base + added_by_current + added_by_me
    merged -= removed_by_current
    merged -= removed_by_me

    merged.uniq.sort
  end

  def render_post_form_record_invalid record
    if record.is_a?(TagName) || record.is_a?(Tag)
      render_validation_error fields: { tags: record.errors.full_messages.map { |message|
        "タグ名 “#{ record.name }”: #{ message }"
      } }
    else
      render_validation_error record
    end
  end

  def normalise_video_ms tags, video_ms_param: params[:video_ms], duration_param: params[:duration]
    return nil unless tags.any? { _1.id == Tag.video.id }

    if video_ms_param.present?
      video_ms = Integer(video_ms_param, exception: false)
      raise VideoMsParseError unless video_ms&.positive?

      return video_ms
    end

    return nil if duration_param.blank?

    video_ms = Tag.time_to_ms!(duration_param.to_s, tag_name: '動画時間')
    raise VideoMsParseError unless video_ms.positive?

    video_ms
  rescue Tag::SectionLiteralParseError
    raise VideoMsParseError
  end

  def validate_video_sections! video_ms, sections
    return unless video_ms

    sections.each_value do |ranges|
      ranges.each do |begin_ms, end_ms|
        if begin_ms >= video_ms
          post = Post.new
          post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です．'
          raise ActiveRecord::RecordInvalid, post
        end

        if end_ms && end_ms > video_ms
          post = Post.new
          post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます．'
          raise ActiveRecord::RecordInvalid, post
        end
      end
    end
  end
end
