require 'rails_helper'
require 'base64'
require 'set'

include ActiveSupport::Testing::TimeHelpers

RSpec.describe 'Posts API', type: :request do
  # create / update で thumbnail.attach は走るが、
  # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
  before do
    allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
    allow(Post).to receive(:resized_thumbnail_attachment) do
      { io: StringIO.new('dummy'),
        filename: 'resized_thumbnail.jpg',
        content_type: 'image/jpeg' }
    end
  end

  def create_nico_tag!(name)
    Tag.find_or_create_by_tag_name!(name, category: :nico)
  end

  def dummy_upload
    real_thumbnail_upload
  end

  def real_thumbnail_upload
    gif =
        Base64.decode64(
            'R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')

    Rack::Test::UploadedFile.new(
      StringIO.new(gif),
      'image/gif',
      original_filename: 'thumbnail.gif'
    )
  end

  def post_write_params params = { }
    { parent_post_ids: '' }.merge(params)
  end

  def create_parent_post! title:, url:
    Post.create!(title:, url:)
  end

  def create_post_version_for!(post)
    version =
        PostVersion.create!(
            post:,
            version_no: 1,
            event_type: 'create',
            title: post.title,
            url: post.url,
            thumbnail_base: post.thumbnail_base,
            video_ms: post.video_ms,
            tags: post.snapshot_tag_names.join(' '),
            parent_post_ids: post.snapshot_parent_post_ids.join(' '),
            original_created_from: post.original_created_from,
            original_created_before: post.original_created_before,
            created_at: post.created_at,
            created_by_user: post.uploaded_user)

    post.update_columns(version_no: version.version_no) if post.has_attribute?(:version_no)
    post.version_no = version.version_no if post.respond_to?(:version_no=)

    version
  end

  def post_update_params(post, params = { })
    base_version =
        post.post_versions.order(version_no: :desc).first ||
        create_post_version_for!(post.reload)

    post_write_params({ base_version_no: base_version.version_no }.merge(params))
  end

  def count_sql_queries
    count = 0

    callback = lambda do |_name, _started, _finished, _id, payload|
      next if payload[:cached]
      next if ['SCHEMA', 'TRANSACTION'].include?(payload[:name])

      count += 1
    end

    ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do
      yield
    end

    count
  end

  let!(:tag_name) { TagName.create!(name: 'spec_tag') }
  let!(:tag)      { Tag.create!(tag_name: tag_name, category: :general) }

  let!(:post_record) do
    Post.create!(title: 'spec post', url: 'https://example.com/spec').tap do |p|
      PostTag.create!(post: p, tag: tag)
    end
  end

  describe 'GET /posts' do
    let!(:user) { create_member_user! }

    let!(:tag_name) { TagName.create!(name: 'spec_tag') }
    let!(:tag) { Tag.create!(tag_name:, category: :general) }
    let!(:tag_name2) { TagName.create!(name: 'unko') }
    let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
    let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }

    let!(:hit_post) do
      Post.create!(uploaded_user: user, title: 'hello spec world',
                   url: 'https://example.com/spec2').tap do |p|
        PostTag.create!(post: p, tag:)
      end
    end

    let!(:miss_post) do
      Post.create!(uploaded_user: user, title: 'unrelated title',
                   url: 'https://example.com/spec3').tap do |p|
        PostTag.create!(post: p, tag: tag2)
      end
    end

    it 'returns posts with tag name in JSON' do
      get '/posts'

      expect(response).to have_http_status(:ok)
      posts = json.fetch('posts')

      # 全postの全tagが name を含むこと
      expect(posts).not_to be_empty
      posts.each do |p|
        expect(p['tags']).to be_an(Array)
        p['tags'].each do |t|
          expect(t).to include('name', 'category', 'has_wiki')
        end
      end
      expect(json['count']).to be_an(Integer)

      # spec_tag を含む投稿が存在すること
      all_tag_names = posts.flat_map { |p| p['tags'].map { |t| t['name'] } }
      expect(all_tag_names).to include('spec_tag')
    end

    it 'keeps children and sections keys in non-detail tag responses' do
      PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil)

      get '/posts'

      expect(response).to have_http_status(:ok)

      hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
      tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }

      expect(tag_json.fetch('children')).to eq([])
      expect(tag_json.fetch('sections')).to eq([
        { 'begin_ms' => 1_000, 'end_ms' => nil }
      ])
    end

    context 'when q is provided' do
      it 'filters posts by q (hit case)' do
        get '/posts', params: { tags: 'spec_tag' }

        expect(response).to have_http_status(:ok)
        posts = json.fetch('posts')
        ids = posts.map { |p| p['id'] }

        expect(ids).to include(hit_post.id)
        expect(ids).not_to include(miss_post.id)
        expect(json['count']).to be_an(Integer)

        posts.each do |p|
          expect(p['tags']).to be_an(Array)
          p['tags'].each do |t|
            expect(t).to include('name', 'category', 'has_wiki')
          end
        end
      end

      it 'filters posts by q (hit case by alias)' do
        get '/posts', params: { tags: 'manko' }

        expect(response).to have_http_status(:ok)
        posts = json.fetch('posts')
        ids = posts.map { |p| p['id'] }

        expect(ids).to include(hit_post.id)
        expect(ids).not_to include(miss_post.id)
        expect(json['count']).to be_an(Integer)

        posts.each do |p|
          expect(p['tags']).to be_an(Array)
          p['tags'].each do |t|
            expect(t).to include('name', 'category', 'has_wiki')
          end
        end
      end

      it 'returns empty posts when nothing matches' do
        get '/posts', params: { tags: 'no_such_keyword_12345' }

        expect(response).to have_http_status(:ok)
        expect(json.fetch('posts')).to eq([])
        expect(json.fetch('count')).to eq(0)
      end
    end

    context 'when tags contain not:' do
      let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') }
      let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) }

      let!(:bar_tag_name) { TagName.create!(name: 'not_spec_bar') }
      let!(:bar_tag) { Tag.create!(tag_name: bar_tag_name, category: :general) }

      let!(:baz_tag_name) { TagName.create!(name: 'not_spec_baz') }
      let!(:baz_tag) { Tag.create!(tag_name: baz_tag_name, category: :general) }

      let!(:foo_alias_tag_name) do
        TagName.create!(name: 'not_spec_foo_alias', canonical: foo_tag_name)
      end

      let!(:foo_only_post) do
        Post.create!(uploaded_user: user, title: 'foo only',
                     url: 'https://example.com/not-spec-foo').tap do |p|
          PostTag.create!(post: p, tag: foo_tag)
        end
      end

      let!(:bar_only_post) do
        Post.create!(uploaded_user: user, title: 'bar only',
                     url: 'https://example.com/not-spec-bar').tap do |p|
          PostTag.create!(post: p, tag: bar_tag)
        end
      end

      let!(:baz_only_post) do
        Post.create!(uploaded_user: user, title: 'baz only',
                     url: 'https://example.com/not-spec-baz').tap do |p|
          PostTag.create!(post: p, tag: baz_tag)
        end
      end

      let!(:foo_bar_post) do
        Post.create!(uploaded_user: user, title: 'foo bar',
                     url: 'https://example.com/not-spec-foo-bar').tap do |p|
          PostTag.create!(post: p, tag: foo_tag)
          PostTag.create!(post: p, tag: bar_tag)
        end
      end

      let!(:foo_baz_post) do
        Post.create!(uploaded_user: user, title: 'foo baz',
                     url: 'https://example.com/not-spec-foo-baz').tap do |p|
          PostTag.create!(post: p, tag: foo_tag)
          PostTag.create!(post: p, tag: baz_tag)
        end
      end

      let(:controlled_ids) do
        [foo_only_post.id, bar_only_post.id, baz_only_post.id,
         foo_bar_post.id, foo_baz_post.id]
      end

      it 'supports not search' do
        get '/posts', params: { tags: 'not:not_spec_foo' }

        expect(response).to have_http_status(:ok)

        expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
          [bar_only_post.id, baz_only_post.id]
        )
      end

      it 'supports alias in not search' do
        get '/posts', params: { tags: 'not:not_spec_foo_alias' }

        expect(response).to have_http_status(:ok)

        expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
          [bar_only_post.id, baz_only_post.id]
        )
      end

      it 'treats multiple not terms as AND when match is omitted' do
        get '/posts', params: { tags: 'not:not_spec_foo not:not_spec_bar' }

        expect(response).to have_http_status(:ok)

        expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
          [baz_only_post.id]
        )
      end

      it 'treats multiple not terms as OR when match=any' do
        get '/posts', params: { tags: 'not:not_spec_foo not:not_spec_bar', match: 'any' }

        expect(response).to have_http_status(:ok)

        expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
          [foo_only_post.id, bar_only_post.id, baz_only_post.id, foo_baz_post.id]
        )
      end

      it 'supports mixed positive and negative search with AND' do
        get '/posts', params: { tags: 'not_spec_foo not:not_spec_bar' }

        expect(response).to have_http_status(:ok)

        expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
          [foo_only_post.id, foo_baz_post.id]
        )
      end

      it 'supports mixed positive and negative search with OR when match=any' do
        get '/posts', params: { tags: 'not_spec_foo not:not_spec_bar', match: 'any' }

        expect(response).to have_http_status(:ok)

        expect(json.fetch('posts').map { |p| p['id'] } & controlled_ids).to match_array(
          [foo_only_post.id, baz_only_post.id, foo_bar_post.id, foo_baz_post.id]
        )
      end
    end

    context 'when url is provided' do
      let!(:url_hit_post) do
        Post.create!(uploaded_user: user, title: 'url hit',
                     url: 'https://example.com/needle-url-xyz').tap do |p|
          PostTag.create!(post: p, tag:)
        end
      end

      let!(:url_miss_post) do
        Post.create!(uploaded_user: user, title: 'url miss',
                     url: 'https://example.com/other-url').tap do |p|
          PostTag.create!(post: p, tag:)
        end
      end

      it 'filters posts by url substring' do
        get '/posts', params: { url: 'needle-url-xyz' }

        expect(response).to have_http_status(:ok)
        ids = json.fetch('posts').map { |p| p['id'] }

        expect(ids).to include(url_hit_post.id)
        expect(ids).not_to include(url_miss_post.id)
        expect(json.fetch('count')).to eq(1)
      end
    end

    context 'when title is provided' do
      let!(:title_hit_post) do
        Post.create!(uploaded_user: user, title: 'needle-title-xyz',
                     url: 'https://example.com/title-hit').tap do |p|
          PostTag.create!(post: p, tag:)
        end
      end

      let!(:title_miss_post) do
        Post.create!(uploaded_user: user, title: 'other title',
                     url: 'https://example.com/title-miss').tap do |p|
          PostTag.create!(post: p, tag:)
        end
      end

      it 'filters posts by title substring' do
        get '/posts', params: { title: 'needle-title-xyz' }

        expect(response).to have_http_status(:ok)
        ids = json.fetch('posts').map { |p| p['id'] }

        expect(ids).to include(title_hit_post.id)
        expect(ids).not_to include(title_miss_post.id)
        expect(json.fetch('count')).to eq(1)
      end
    end

    context 'when created_from/created_to are provided' do
      let(:t_created_hit) { Time.zone.local(2010, 1, 5, 12, 0, 0) }
      let(:t_created_miss) { Time.zone.local(2012, 1, 5, 12, 0, 0) }

      let!(:created_hit_post) do
        travel_to(t_created_hit) do
          Post.create!(uploaded_user: user, title: 'created hit',
                       url: 'https://example.com/created-hit').tap do |p|
            PostTag.create!(post: p, tag:)
          end
        end
      end

      let!(:created_miss_post) do
        travel_to(t_created_miss) do
          Post.create!(uploaded_user: user, title: 'created miss',
                       url: 'https://example.com/created-miss').tap do |p|
            PostTag.create!(post: p, tag:)
          end
        end
      end

      it 'filters posts by created_at range' do
        get '/posts', params: {
          created_from: Time.zone.local(2010, 1, 1, 0, 0, 0).iso8601,
          created_to: Time.zone.local(2010, 12, 31, 23, 59, 59).iso8601
        }

        expect(response).to have_http_status(:ok)
        ids = json.fetch('posts').map { |p| p['id'] }

        expect(ids).to include(created_hit_post.id)
        expect(ids).not_to include(created_miss_post.id)
        expect(json.fetch('count')).to eq(1)
      end
    end

    context 'when updated_from/updated_to are provided' do
      let(:t0) { Time.zone.local(2011, 2, 1, 12, 0, 0) }
      let(:t1) { Time.zone.local(2011, 2, 10, 12, 0, 0) }

      let!(:updated_hit_post) do
        p = nil
        travel_to(t0) do
          p = Post.create!(uploaded_user: user, title: 'updated hit',
                           url: 'https://example.com/updated-hit').tap do |pp|
            PostTag.create!(post: pp, tag:)
          end
        end
        travel_to(t1) do
          p.update!(title: 'updated hit v2')
        end
        p
      end

      let!(:updated_miss_post) do
        travel_to(Time.zone.local(2013, 1, 1, 12, 0, 0)) do
          Post.create!(uploaded_user: user, title: 'updated miss',
                       url: 'https://example.com/updated-miss').tap do |p|
            PostTag.create!(post: p, tag:)
          end
        end
      end

      it 'filters posts by updated_at range' do
        get '/posts', params: {
          updated_from: Time.zone.local(2011, 2, 5, 0, 0, 0).iso8601,
          updated_to: Time.zone.local(2011, 2, 20, 23, 59, 59).iso8601
        }

        expect(response).to have_http_status(:ok)
        ids = json.fetch('posts').map { |p| p['id'] }

        expect(ids).to include(updated_hit_post.id)
        expect(ids).not_to include(updated_miss_post.id)
        expect(json.fetch('count')).to eq(1)
      end
    end

    context 'when original_created_from/original_created_to are provided' do
      # 注意: controller の現状ロジックに合わせてる
      # original_created_from は `original_created_before > ?`
      # original_created_to   は `original_created_from <= ?`

      let!(:oc_hit_post) do
        Post.create!(uploaded_user: user, title: 'oc hit',
                     url: 'https://example.com/oc-hit',
                     original_created_from: Time.zone.local(2015, 1, 1, 0, 0, 0),
                     original_created_before: Time.zone.local(2015, 1, 10, 0, 0, 0)).tap do |p|
          PostTag.create!(post: p, tag:)
        end
      end

      # original_created_from の条件は「original_created_before > param」なので、
      # before が param 以下になるようにする（ただし before >= from は守る）
      let!(:oc_miss_post_for_from) do
        Post.create!(
          uploaded_user: user,
          title: 'oc miss for from',
          url: 'https://example.com/oc-miss-from',
          original_created_from: Time.zone.local(2014, 12, 1, 0, 0, 0),
          original_created_before: Time.zone.local(2015, 1, 1, 0, 0, 0)
        ).tap { |p| PostTag.create!(post: p, tag:) }
      end

      # original_created_to の条件は「original_created_from <= param」なので、
      # from が param より後になるようにする（before >= from は守る）
      let!(:oc_miss_post_for_to) do
        Post.create!(
          uploaded_user: user,
          title: 'oc miss for to',
          url: 'https://example.com/oc-miss-to',
          original_created_from: Time.zone.local(2015, 2, 1, 0, 0, 0),
          original_created_before: Time.zone.local(2015, 2, 10, 0, 0, 0)
        ).tap { |p| PostTag.create!(post: p, tag:) }
      end

      it 'filters posts by original_created_from (current controller behavior)' do
        get '/posts', params: {
          original_created_from: Time.zone.local(2015, 1, 5, 0, 0, 0).iso8601
        }

        expect(response).to have_http_status(:ok)
        ids = json.fetch('posts').map { |p| p['id'] }

        expect(ids).to include(oc_hit_post.id)
        expect(ids).not_to include(oc_miss_post_for_from.id)
        expect(json.fetch('count')).to eq(2)
      end

      it 'filters posts by original_created_to (current controller behavior)' do
        get '/posts', params: {
          original_created_to: Time.zone.local(2015, 1, 15, 0, 0, 0).iso8601
        }

        expect(response).to have_http_status(:ok)
        ids = json.fetch('posts').map { |p| p['id'] }

        expect(ids).to include(oc_hit_post.id)
        expect(ids).not_to include(oc_miss_post_for_to.id)
        expect(json.fetch('count')).to eq(2)
      end
    end
  end

  describe 'GET /posts/:id' do
    subject(:request) { get "/posts/#{post_id}" }

    context 'when post exists' do
      let(:post_id) { post_record.id }

      it 'returns post with tag tree + related + viewed' do
        request
        expect(response).to have_http_status(:ok)

        expect(json).to include('id' => post_record.id)
        expect(json).to have_key('tags')
        expect(json['tags']).to be_an(Array)

        # show は build_tag_tree_for を使うので、tags はツリー形式（children 付き）
        node = json['tags'][0]
        expect(node).to include('id', 'name', 'category', 'post_count', 'children', 'has_wiki')
        expect(node['name']).to eq('spec_tag')

        expect(json).to have_key('related')
        expect(json['related']).to be_an(Array)

        expect(json).to have_key('viewed')
        expect([true, false]).to include(json['viewed'])
      end

      it 'omits deprecated tags' do
        deprecated_tag = Tag.create!(
          name: 'deprecated_post_tag',
          category: :general,
          deprecated_at: Time.current
        )
        PostTag.create!(post: post_record, tag: deprecated_tag)

        request

        expect(response).to have_http_status(:ok)
        tag_names = json.fetch('tags').flat_map { |node|
          [node.fetch('name')] + node.fetch('children').map { |child| child.fetch('name') }
        }
        expect(tag_names).to include('spec_tag')
        expect(tag_names).not_to include('deprecated_post_tag')
      end

      context 'when post has parent, child, and sibling posts' do
        let!(:parent_post) do
          create_parent_post!(
            title: 'shared parent post',
            url: 'https://example.com/shared-parent-post'
          )
        end

        let!(:child_post) do
          Post.create!(
            title: 'child post',
            url: 'https://example.com/show-child-post'
          )
        end

        let!(:sibling_post) do
          Post.create!(
            title: 'sibling post',
            url: 'https://example.com/show-sibling-post'
          )
        end

        before do
          PostImplication.create!(
            post: post_record,
            parent_post:
          )

          PostImplication.create!(
            post: child_post,
            parent_post: post_record
          )

          PostImplication.create!(
            post: sibling_post,
            parent_post:
          )
        end

        it 'returns parent_posts, child_posts, and sibling_posts' do
          get "/posts/#{post_record.id}"

          expect(response).to have_http_status(:ok)

          parent_ids = json.fetch('parent_posts').map { |p| p.fetch('id') }
          child_ids = json.fetch('child_posts').map { |p| p.fetch('id') }

          expect(parent_ids).to include(parent_post.id)
          expect(child_ids).to include(child_post.id)

          sibling_posts_by_parent = json.fetch('sibling_posts')
          siblings = sibling_posts_by_parent.fetch(parent_post.id.to_s)

          sibling_ids = siblings.map { |p| p.fetch('id') }
          expect(sibling_ids).to include(post_record.id)
          expect(sibling_ids).to include(sibling_post.id)
        end
      end

      it 'does not issue a query per tag or related post' do
        user = create_member_user!

        tags =
            15.times.map do |i|
              tag_name = TagName.create!(name: "show_query_tag_#{ i }")
              tag = Tag.create!(tag_name:, category: :general)
              TagName.create!(name: "show_query_alias_#{ i }", canonical: tag_name)
              PostTag.create!(post: post_record, tag:)
              tag
            end

        tags.each_cons(2) do |parent_tag, child_tag|
          TagImplication.create!(parent_tag:, tag: child_tag)
        end

        parent_post = Post.create!(
          title: 'query parent post',
          url: 'https://example.com/query-parent-post'
        )
        sibling_post = Post.create!(
          title: 'query sibling post',
          url: 'https://example.com/query-sibling-post'
        )
        child_post = Post.create!(
          title: 'query child post',
          url: 'https://example.com/query-child-post'
        )

        PostImplication.create!(post: post_record, parent_post:)
        PostImplication.create!(post: sibling_post, parent_post:)
        PostImplication.create!(post: child_post, parent_post: post_record)

        20.times do |i|
          related_post = Post.create!(
            title: "query related post #{ i }",
            url: "https://example.com/query-related-post-#{ i }"
          )
          PostSimilarity.create!(post: post_record,
                                 target_post: related_post,
                                 cos: 1.0 - (i / 100.0))
        end

        query_count =
            count_sql_queries do
              get "/posts/#{ post_record.id }",
                  headers: { 'X-Transfer-Code' => user.inheritance_code }
            end

        expect(response).to have_http_status(:ok)
        expect(query_count).to be <= 45
      end
    end

    context 'when post does not exist' do
      let(:post_id) { 999_999_999 }

      it 'returns 404' do
        request
        expect(response).to have_http_status(:not_found)
      end
    end
  end

  describe 'GET /posts/metadata' do
    let(:member) { create(:user, :member) }

    it 'returns compact existing post data without fetching external metadata' do
      sign_in_as(member)
      existing = create(
          :post,
          title: 'existing post',
          url: 'https://example.com/existing')
      existing.thumbnail.attach(
          io: StringIO.new('thumbnail'),
          filename: 'thumbnail.jpg',
          content_type: 'image/jpeg')
      expect(PostMetadataFetcher).not_to receive(:fetch)

      get '/posts/metadata', params: { url: 'https://example.com/existing' }

      expect(response).to have_http_status(:ok)
      expect(json).to include(
          'url' => existing.url,
          'existing_post_id' => existing.id,
          'field_warnings' => { })
      expect(json.fetch('existing_post')).to include(
          'id' => existing.id,
          'title' => existing.title,
          'url' => existing.url,
          'thumbnail_base' => existing.thumbnail_base)
      expect(json.dig('existing_post', 'thumbnail'))
          .to include('/rails/active_storage/blobs/proxy/')
    end

    it 'returns fetched metadata and structured display tags' do
      sign_in_as(member)
      allow(Preview::UrlSafety).to receive(:validate)
      allow(PostMetadataFetcher).to receive(:fetch).and_return(
          title: 'fetched title',
          thumbnail_base: 'https://example.com/thumbnail.jpg',
          tags: 'character:虹夏',
          display_tags: [{ name: '虹夏', category: 'character' }],
          original_created_from: nil,
          original_created_before: nil,
          duration: '1:00',
          video_ms: 60_000)

      get '/posts/metadata', params: { url: 'https://example.com/new' }

      expect(response).to have_http_status(:ok)
      expect(json).to include(
          'url' => 'https://example.com/new',
          'title' => 'fetched title',
          'duration' => '1:00',
          'video_ms' => 60_000,
          'field_warnings' => { })
      expect(json.fetch('display_tags')).to eq(
          [{ 'name' => '虹夏', 'category' => 'character' }])
    end

    it 'returns URL validation errors as 422' do
      sign_in_as(member)

      get '/posts/metadata', params: { url: 'file:///etc/passwd' }

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json.fetch('errors')).to have_key('url')
    end
  end

  describe 'POST /posts' do
    let(:member) { create(:user, :member) }
    let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }

    it '401 when not logged in' do
      sign_out
      post '/posts', params: post_write_params(title: 't', url: 'https://example.com/x', tags: 'a',
                                               thumbnail: dummy_upload)

      expect(response).to have_http_status(:unauthorized)
    end

    it '403 when not member' do
      sign_in_as(create(:user, role: 'guest'))
      post '/posts', params: post_write_params(title: 't', url: 'https://example.com/x', tags: 'a',
                                               thumbnail: dummy_upload)
      expect(response).to have_http_status(:forbidden)
    end

    it 'dry-runs without persisting posts or new tags' do
      sign_in_as(member)
      counts = [Post.count, Tag.count, TagName.count]

      post '/posts?dry=1', params: post_write_params(
          title: 'dry-run post',
          url: 'https://example.com/dry-run',
          tags: 'character:new_dry_run_tag')

      expect(response).to have_http_status(:ok)
      expect(json).to include(
          'url' => 'https://example.com/dry-run',
          'tags' => 'new_dry_run_tag',
          'existing_post_id' => nil)
      expect(json.fetch('display_tags')).to eq(
          [{ 'name' => 'new_dry_run_tag',
             'category' => 'character',
             'section_literals' => [] }])
      expect([Post.count, Tag.count, TagName.count]).to eq(counts)
    end

    it 'dry-runs an existing URL without validating its upload' do
      sign_in_as(member)
      existing = create(:post, url: 'https://example.com/dry-existing')
      invalid_upload = Rack::Test::UploadedFile.new(
          StringIO.new('<svg></svg>'),
          'image/png',
          original_filename: 'thumbnail.png')

      post '/posts?dry=1', params: post_write_params(
          title: '',
          url: existing.url,
          tags: '',
          thumbnail: invalid_upload)

      expect(response).to have_http_status(:ok)
      expect(json).to include('existing_post_id' => existing.id)
      expect(json.fetch('field_warnings')).not_to have_key('thumbnail_base')
    end

    it '201 and creates post + tags when member' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'new post',
        url: 'https://example.com/new',
        tags: 'spec_tag', # 既存タグ名を投げる
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)
      expect(json).to include('id', 'title', 'url')

      # tags が name を含むこと（API 側の serialization が正しいこと）
      expect(json).to have_key('tags')
      expect(json['tags']).to be_an(Array)
      expect(json['tags'][0]).to have_key('name')
    end

    it '201 when posting manually with a thumbnail' do
      sign_in_as(member)
      allow(Post).to receive(:resized_thumbnail_attachment).and_call_original

      post '/posts', params: post_write_params(
        title: 'thumbnail post',
        url: 'https://example.com/thumbnail-post',
        tags: 'spec_tag',
        thumbnail: real_thumbnail_upload
      )

      expect(response).to have_http_status(:created)
      post_record = Post.find(json.fetch('id'))
      expect(post_record.thumbnail).to be_attached
      expect(post_record.thumbnail.blob.content_type).to eq('image/jpeg')
      expect { post_record.thumbnail.download }.not_to raise_error
    end

    it 'resizes the thumbnail before the create transaction begins' do
      sign_in_as(member)

      open_transactions = []
      baseline_open_transactions = Post.connection.open_transactions
      allow(Post).to receive(:resized_thumbnail_attachment) do |_upload|
        open_transactions << Post.connection.open_transactions
        { io: StringIO.new('dummy'),
          filename: 'resized_thumbnail.jpg',
          content_type: 'image/jpeg' }
      end

      post '/posts', params: post_write_params(
        title: 'transaction post',
        url: 'https://example.com/transaction-post',
        tags: 'spec_tag',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)
      expect(open_transactions).to eq(
          [baseline_open_transactions, baseline_open_transactions])
    end

    it 'returns 422 and does not create a post when thumbnail resize fails' do
      sign_in_as(member)
      allow(Post).to receive(:resized_thumbnail_attachment).and_raise(MiniMagick::Error)

      expect {
        post '/posts', params: post_write_params(
          title: 'broken thumbnail post',
          url: 'https://example.com/broken-thumbnail-post',
          tags: 'spec_tag',
          thumbnail: dummy_upload
        )
      }.not_to change(Post, :count)

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json.fetch('errors')).to include(
        'thumbnail' => ['サムネイル画像の変換に失敗しました．']
      )
    end

    it '201 and creates post + tags when member and tags have aliases' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'new post',
        url: 'https://example.com/new',
        tags: 'manko', # 既存タグ名を投げる
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)
      expect(json).to include('id', 'title', 'url')

      # tags が name を含むこと（API 側の serialization が正しいこと）
      names = json.fetch('tags').map { |t| t['name'] }
      expect(names).to include('spec_tag')
      expect(names).not_to include('manko')
    end

    it 'rejects a deprecated tag specified directly' do
      Tag.create!(
        name: 'deprecated_direct_tag',
        category: :general,
        deprecated_at: Time.current
      )
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'new post',
        url: 'https://example.com/deprecated-direct-tag',
        tags: 'deprecated_direct_tag',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json.fetch('errors')).to include(
        'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
      )
    end

    it 'expands through multiple deprecated parent tags and saves active ancestors' do
      child = Tag.create!(name: 'active_child', category: :general)
      deprecated_parent = Tag.create!(
        name: 'deprecated_parent',
        category: :general,
        deprecated_at: Time.current
      )
      deprecated_grandparent = Tag.create!(
        name: 'deprecated_grandparent',
        category: :general,
        deprecated_at: Time.current
      )
      active_grandparent = Tag.create!(name: 'active_grandparent', category: :general)
      TagImplication.create!(tag: child, parent_tag: deprecated_parent)
      TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
      TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_grandparent)
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'expanded post',
        url: 'https://example.com/expanded-deprecated-parent',
        tags: 'active_child',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)
      saved_names = Post.find(json.fetch('id')).tags.map(&:name)
      expect(saved_names).to include('active_child', 'active_grandparent')
      expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent')
    end

    it 'returns validation error for an invalid section literal' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'invalid section literal',
        url: 'https://example.com/invalid-section-literal',
        tags: 'spec_tag[1:aa-2:00]',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json).to include(
        'type' => 'validation_error',
        'message' => '入力内容を確認してください．'
      )
      expect(json.fetch('errors')).to include(
        'tags' => ['タグ区間の記法が不正です．']
      )
    end

    it 'creates a video post with duration' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'video post',
        url: 'https://example.com/video-post',
        tags: '動画 spec_tag',
        duration: '3:00.500',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)
      expect(Post.find(json.fetch('id')).video_ms).to eq(180_500)
      expect(json.fetch('video_ms')).to eq(180_500)
    end

    it 'creates a video post with number input duration seconds' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'video post seconds',
        url: 'https://example.com/video-post-seconds',
        tags: '動画 spec_tag',
        duration: '180.5',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)
      expect(Post.find(json.fetch('id')).video_ms).to eq(180_500)
      expect(json.fetch('video_ms')).to eq(180_500)
    end

    it 'clears video_ms when the saved tags do not include 動画' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'non video post',
        url: 'https://example.com/non-video-post',
        tags: 'spec_tag',
        duration: '3:00',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)
      expect(Post.find(json.fetch('id')).video_ms).to be_nil
    end

    it 'returns validation error when a bounded section exceeds duration' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'too long section',
        url: 'https://example.com/too-long-section',
        tags: '動画 伊地知ニジカ[2:50-3:10]',
        duration: '3:00',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json.fetch('errors')).to include(
        'video_ms' => ['タグ区間の終端が動画時間を超えてゐます．']
      )
    end

    it 'saves open-ended sections with end_ms NULL' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'open ended section literal',
        url: 'https://example.com/open-ended-section-literal',
        tags: '動画 伊地知ニジカ[1:00-]',
        duration: '3:00',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)

      created_post = Post.find(json.fetch('id'))
      tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
      section = PostTagSection.find_by!(post: created_post, tag:)

      expect(section.begin_ms).to eq(60_000)
      expect(section.end_ms).to be_nil
    end

    it 'does not save sections for [-]' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'fully open section literal',
        url: 'https://example.com/fully-open-section-literal',
        tags: '伊地知ニジカ[-]',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)

      created_post = Post.find(json.fetch('id'))
      tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
      expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
    end

    it 'does not save sections for [0:00-]' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'zero open ended section literal',
        url: 'https://example.com/zero-open-ended-section-literal',
        tags: '伊地知ニジカ[0:00-]',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)

      created_post = Post.find(json.fetch('id'))
      tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
      expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
    end

    it 'returns end_ms null for open-ended sections in show response' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'show open ended section literal',
        url: 'https://example.com/show-open-ended-section-literal',
        tags: '動画 伊地知ニジカ[1:00-]',
        duration: '3:00',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)

      get "/posts/#{ json.fetch('id') }"

      expect(response).to have_http_status(:ok)
      tag_json = json.fetch('tags').find { |item| item['name'] == '伊地知ニジカ' }
      expect(tag_json.fetch('sections')).to eq([
        { 'begin_ms' => 60_000, 'end_ms' => nil }
      ])
    end

    it 'allows open-ended sections when begin is within duration' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'valid open ended section literal',
        url: 'https://example.com/valid-open-ended-section-literal',
        tags: '動画 伊地知ニジカ[1:00-]',
        duration: '3:00',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:created)
    end

    it 'rejects open-ended sections when begin equals duration' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
        title: 'invalid open ended section literal',
        url: 'https://example.com/invalid-open-ended-section-literal',
        tags: '動画 伊地知ニジカ[3:00-]',
        duration: '3:00',
        thumbnail: dummy_upload
      )

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json.fetch('errors')).to include(
        'video_ms' => ['タグ区間の開始が動画時間以上です．']
      )
    end

    context 'when nico tag already exists in tags' do
      before do
        Tag.find_undiscard_or_create_by!(
            tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
            category: :nico)
      end

      it 'returns 422 with tag field errors' do
        sign_in_as(member)

        post '/posts', params: post_write_params(
          title: 'new post',
          url: 'https://example.com/nico-tag-post',
          tags: 'nico:nico_tag',
          thumbnail: dummy_upload
        )

        expect(response).to have_http_status(:unprocessable_entity), response.body
        expect(json).to include(
            'type' => 'validation_error',
            'message' => '入力内容を確認してください．',
            'base_errors' => [])
        expect(json.fetch('errors')).to include(
            'tags' => ['ニコニコ・タグは直接指定できません．'])
      end
    end

    context 'when url is blank' do
      it 'returns 422' do
        sign_in_as(member)

        post '/posts', params: post_write_params(
            title: 'new post',
            url: '   ',
            tags: 'spec_tag', # 既存タグ名を投げる
            thumbnail: dummy_upload)

        expect(response).to have_http_status(:unprocessable_entity)
      end
    end

    context 'when url is invalid' do
      it 'returns 422' do
        sign_in_as(member)

        post '/posts', params: post_write_params(
            title: 'new post',
            url: 'ぼざクリタグ広場',
            tags: 'spec_tag', # 既存タグ名を投げる
            thumbnail: dummy_upload)

        expect(response).to have_http_status(:unprocessable_entity)
      end
    end

    context 'when parent_post_ids is provided' do
      let!(:parent_post_1) do
        create_parent_post!(
          title: 'parent post 1',
          url: 'https://example.com/parent-post-1'
        )
      end

      let!(:parent_post_2) do
        create_parent_post!(
          title: 'parent post 2',
          url: 'https://example.com/parent-post-2'
        )
      end

      it 'creates post implications for parent posts' do
        sign_in_as(member)

        expect {
          post '/posts', params: {
              title: 'child post',
              url: 'https://example.com/child-post',
              tags: 'spec_tag',
              parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}",
              thumbnail: dummy_upload }
        }.to change(PostImplication, :count).by(2)

        expect(response).to have_http_status(:created)

        created_post = Post.find(json.fetch('id'))
        expect(created_post.parent_posts.order(:id).pluck(:id)).to eq(
          [parent_post_1.id, parent_post_2.id].sort
        )

        expect(PostImplication.exists?(
          post_id: created_post.id,
          parent_post_id: parent_post_1.id
        )).to be(true)

        expect(PostImplication.exists?(
          post_id: created_post.id,
          parent_post_id: parent_post_2.id
        )).to be(true)
      end

      it 'deduplicates parent_post_ids' do
        sign_in_as(member)

        expect {
          post '/posts', params: post_write_params(
            title: 'dedup child post',
            url: 'https://example.com/dedup-child-post',
            tags: 'spec_tag',
            parent_post_ids: "#{parent_post_1.id} #{parent_post_1.id}",
            thumbnail: dummy_upload
          )
        }.to change(PostImplication, :count).by(1)

        expect(response).to have_http_status(:created)

        created_post = Post.find(json.fetch('id'))
        expect(created_post.parent_posts.pluck(:id)).to eq([parent_post_1.id])
      end

      it 'records parent_post_ids in post version' do
        sign_in_as(member)

        post '/posts', params: post_write_params(
          title: 'versioned child post',
          url: 'https://example.com/versioned-child-post',
          tags: 'spec_tag',
          parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}",
          thumbnail: dummy_upload
        )

        expect(response).to have_http_status(:created)

        created_post = Post.find(json.fetch('id'))
        version = PostVersion.find_by!(post: created_post, version_no: 1)

        expect(version.parent_post_ids.split.map(&:to_i)).to eq(
          [parent_post_1.id, parent_post_2.id].sort
        )
      end
    end

    context 'when parent_post_ids is missing' do
      it 'returns 422' do
        sign_in_as(member)

        expect {
          post '/posts', params: {
              title: 'missing parent_post_ids',
              url: 'https://example.com/missing-parent-post-ids',
              tags: 'spec_tag',
              thumbnail: dummy_upload }
        }.not_to change(Post, :count)

        expect(response).to have_http_status(:unprocessable_entity)
        expect(json.fetch('errors')).to be_present
      end
    end

    context 'when parent_post_ids includes invalid token' do
      it 'returns 422 and does not create post' do
        sign_in_as(member)

        expect {
          post '/posts', params: post_write_params(
            title: 'invalid parent ids',
            url: 'https://example.com/invalid-parent-ids',
            tags: 'spec_tag',
            parent_post_ids: 'abc',
            thumbnail: dummy_upload
          )
        }.not_to change(Post, :count)

        expect(response).to have_http_status(:unprocessable_entity)
        expect(json.fetch('errors')).to be_present
      end
    end

    context 'when parent_post_ids includes nonexistent post id' do
      it 'returns 422 and does not create post implication' do
        sign_in_as(member)

        expect {
          post '/posts', params: post_write_params(
            title: 'missing parent post',
            url: 'https://example.com/missing-parent-post',
            tags: 'spec_tag',
            parent_post_ids: '999999999',
            thumbnail: dummy_upload
          )
        }.not_to change(PostImplication, :count)

        expect(response).to have_http_status(:unprocessable_entity)
        expect(json.fetch('errors')).to be_present
      end
    end
  end

  describe 'POST /posts/bulk' do
    let(:member) { create(:user, :member) }

    it 'parses the manifest and indexed thumbnail parts' do
      sign_in_as(member)
      manifest = [{ 'url' => 'https://example.com/bulk',
                    'title' => 'bulk post',
                    'tags' => 'spec_tag',
                    'parent_post_ids' => '' }]
      creator = instance_double(
          PostBulkCreator,
          run: { results: [{ status: 'created', post: { id: 123 } }] })
      allow(PostBulkCreator).to receive(:new).and_return(creator)

      post '/posts/bulk', params: {
          posts: JSON.generate(manifest),
          thumbnails: { '0' => real_thumbnail_upload } }

      expect(response).to have_http_status(:ok)
      expect(json.fetch('results')).to eq(
          [{ 'status' => 'created', 'post' => { 'id' => 123 } }])
      expect(PostBulkCreator).to have_received(:new) do |arguments|
        expect(arguments[:actor]).to eq(member)
        expect(arguments[:posts]).to eq(manifest)
        expect(arguments[:thumbnails].keys).to eq([0])
        expect(arguments[:host]).to eq('http://www.example.com')
      end
    end

    it 'rejects malformed manifests as a request-level error' do
      sign_in_as(member)

      post '/posts/bulk', params: {
          posts: '{',
          thumbnails: { '0' => real_thumbnail_upload } }

      expect(response).to have_http_status(:bad_request)
      expect(json.fetch('message')).to eq('posts manifest の JSON が不正です．')
    end
  end

  describe 'PUT /posts/:id' do
    let(:member) { create(:user, :member) }

    it '401 when not logged in' do
      sign_out
      put "/posts/#{post_record.id}", params: post_update_params(
          post_record, title: 'updated', tags: 'spec_tag')
      expect(response).to have_http_status(:unauthorized)
    end

    it '403 when not member' do
      sign_in_as(create(:user, role: 'guest'))
      put "/posts/#{post_record.id}", params: post_update_params(
          post_record, title: 'updated', tags: 'spec_tag')
      expect(response).to have_http_status(:forbidden)
    end

    it '200 and updates title + resync tags when member' do
      sign_in_as(member)

      tn2 = TagName.create!(name: 'spec_tag_2')
      Tag.create!(tag_name: tn2, category: :general)

      put "/posts/#{post_record.id}", params: post_update_params(
          post_record,
          title: 'updated title',
          tags: 'spec_tag_2')

      expect(response).to have_http_status(:ok)
      expect(json).to have_key('tags')
      expect(json['tags']).to be_an(Array)

      names = json['tags'].map { |n| n['name'] }
      expect(names).to include('spec_tag_2')
    end

    it 'rejects a deprecated tag specified directly' do
      Tag.create!(
        name: 'deprecated_update_tag',
        category: :general,
        deprecated_at: Time.current
      )
      sign_in_as(member)

      put "/posts/#{ post_record.id }", params: post_update_params(
        post_record,
        title: 'updated title',
        tags: 'deprecated_update_tag'
      )

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json.fetch('errors')).to include(
        'tags' => ['廃止済みタグは付与できません．']
      )
    end

    context 'when nico tag already exists in tags' do
      before do
        Tag.find_undiscard_or_create_by!(
            tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
            category: :nico)
      end

      it 'returns 422 with tag field errors' do
        sign_in_as(member)

        put "/posts/#{post_record.id}", params: post_update_params(
            post_record,
            title: 'updated title',
            tags: 'nico:nico_tag')

        expect(response).to have_http_status(:unprocessable_entity), response.body
        expect(json).to include(
            'type' => 'validation_error',
            'message' => '入力内容を確認してください．',
            'base_errors' => [])
        expect(json.fetch('errors')).to include(
            'tags' => ['ニコニコ・タグは直接指定できません．'])
      end
    end

    context 'when parent_post_ids is provided' do
      let!(:old_parent_post) do
        create_parent_post!(
          title: 'old parent post',
          url: 'https://example.com/old-parent-post'
        )
      end

      let!(:new_parent_post_1) do
        create_parent_post!(
          title: 'new parent post 1',
          url: 'https://example.com/new-parent-post-1'
        )
      end

      let!(:new_parent_post_2) do
        create_parent_post!(
          title: 'new parent post 2',
          url: 'https://example.com/new-parent-post-2'
        )
      end

      before do
        PostImplication.create!(
          post: post_record,
          parent_post: old_parent_post
        )
      end

      it 'replaces parent posts' do
        sign_in_as(member)

        put "/posts/#{post_record.id}", params: post_update_params(
            post_record,
            title: 'updated title',
            tags: 'spec_tag',
            parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}")

        expect(response).to have_http_status(:ok)

        expect(post_record.reload.parent_posts.order(:id).pluck(:id)).to eq(
          [new_parent_post_1.id, new_parent_post_2.id].sort
        )

        expect(PostImplication.exists?(
          post_id: post_record.id,
          parent_post_id: old_parent_post.id
        )).to be(false)
      end

      it 'clears parent posts when parent_post_ids is blank' do
        sign_in_as(member)

        put "/posts/#{post_record.id}", params: post_update_params(
          post_record,
          title: 'updated title',
          tags: 'spec_tag',
          parent_post_ids: ''
        )

        expect(response).to have_http_status(:ok)
        expect(post_record.reload.parent_posts).to be_empty
      end

      it 'records changed parent_post_ids in post version' do
        sign_in_as(member)
        create_post_version_for!(post_record.reload)

        put "/posts/#{post_record.id}", params: post_update_params(
          post_record,
          title: 'updated title',
          tags: 'spec_tag',
          parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}"
        )

        expect(response).to have_http_status(:ok)

        version = post_record.reload.post_versions.order(:version_no).last

        expect(version.version_no).to eq(2)
        expect(version.parent_post_ids.split.map(&:to_i)).to eq(
          [new_parent_post_1.id, new_parent_post_2.id].sort
        )
      end
    end

    context 'when parent_post_ids is missing' do
      it 'returns 422' do
        sign_in_as(member)

        base_version = create_post_version_for!(post_record.reload)

        put "/posts/#{post_record.id}", params: {
            base_version_no: base_version.version_no,
            title: 'updated title',
            tags: 'spec_tag' }

        expect(response).to have_http_status(:unprocessable_entity)
        expect(json.fetch('errors')).to be_present
      end
    end

    context 'when parent_post_ids includes invalid token' do
      it 'returns 422 and does not change parent posts' do
        sign_in_as(member)

        parent_post = create_parent_post!(
          title: 'valid parent post',
          url: 'https://example.com/valid-parent-post'
        )

        PostImplication.create!(
          post: post_record,
          parent_post:
        )

        put "/posts/#{post_record.id}", params: post_update_params(
          post_record,
          title: 'updated title',
          tags: 'spec_tag',
          parent_post_ids: 'abc'
        )

        expect(response).to have_http_status(:unprocessable_entity)
        expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id])
      end
    end

    context 'when parent_post_ids includes nonexistent post id' do
      it 'returns 422 and does not change parent posts' do
        sign_in_as(member)

        parent_post = create_parent_post!(
          title: 'existing parent post',
          url: 'https://example.com/existing-parent-post'
        )

        PostImplication.create!(
          post: post_record,
          parent_post:
        )

        put "/posts/#{post_record.id}", params: post_update_params(
          post_record,
          title: 'updated title',
          tags: 'spec_tag',
          parent_post_ids: '999999999'
        )

        expect(response).to have_http_status(:unprocessable_entity)
        expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id])
      end
    end

    context 'when parent_post_ids includes self id' do
      it 'returns 422 and does not create self implication' do
        sign_in_as(member)

        put "/posts/#{post_record.id}", params: post_update_params(
          post_record,
          title: 'updated title',
          tags: 'spec_tag',
          parent_post_ids: post_record.id.to_s
        )

        expect(response).to have_http_status(:unprocessable_entity)

        expect(PostImplication.exists?(
          post_id: post_record.id,
          parent_post_id: post_record.id
        )).to be(false)
      end
    end

    context 'with optimistic locking' do
      let!(:no_deerjikist_tag) { Tag.no_deerjikist }

      before do
        PostTag.create!(post: post_record, tag: no_deerjikist_tag)
      end

      it '400 when base_version_no is missing without force' do
        sign_in_as(member)

        put "/posts/#{post_record.id}", params: post_write_params(
            title: 'updated title',
            tags: 'spec_tag')

        expect(response).to have_http_status(:bad_request)
      end

      it '400 when force and merge are both true' do
        sign_in_as(member)

        put "/posts/#{post_record.id}", params: post_write_params(
            title: 'updated title',
            tags: 'spec_tag',
            force: '1',
            merge: '1')

        expect(response).to have_http_status(:bad_request)
      end

      it '409 when scalar fields are changed both by current and incoming updates' do
        sign_in_as(member)

        base_version = create_post_version_for!(post_record.reload)

        post_record.update!(title: 'updated by other user')
        PostVersionRecorder.record!(
            post: post_record.reload,
            event_type: :update,
            created_by_user: member)

        put "/posts/#{post_record.id}", params: post_write_params(
            base_version_no: base_version.version_no,
            title: 'updated by me',
            tags: "spec_tag #{Tag.no_deerjikist.name}")

        expect(response).to have_http_status(:conflict)

        expect(json.fetch('error')).to eq('conflict')
        expect(json.fetch('base_version_no')).to eq(base_version.version_no)
        expect(json.fetch('current_version_no')).to eq(2)
        expect(json.fetch('mergeable')).to be(false)

        conflict_fields = json.fetch('conflicts').map { |change| change.fetch('field') }
        expect(conflict_fields).to include('title')

        expect(post_record.reload.title).to eq('updated by other user')
      end

      it 'returns mergeable 409 for stale non-conflicting tag changes without merge' do
        sign_in_as(member)

        base_version = create_post_version_for!(post_record.reload)

        current_tag = Tag.find_or_create_by_tag_name!('current_added_tag', category: :general)
        PostTag.create!(post: post_record, tag: current_tag, created_user: member)

        PostVersionRecorder.record!(
            post: post_record.reload,
            event_type: :update,
            created_by_user: member)

        put "/posts/#{post_record.id}", params: post_write_params(
            base_version_no: base_version.version_no,
            title: post_record.title,
            tags: "spec_tag #{Tag.no_deerjikist.name} incoming_added_tag")

        expect(response).to have_http_status(:conflict)

        expect(json.fetch('mergeable')).to be(true)

        tag_change = json.fetch('changes').find { |change| change.fetch('field') == 'tag_names' }
        expect(tag_change).to be_present
        expect(tag_change.fetch('conflict')).to be(false)
        expect(tag_change.fetch('added_by_current')).to include('current_added_tag')
        expect(tag_change.fetch('added_by_me')).to include('incoming_added_tag')
      end

      it 'merges non-conflicting stale tag changes when merge is true' do
        sign_in_as(member)

        base_version = create_post_version_for!(post_record.reload)

        current_tag = Tag.find_or_create_by_tag_name!('current_merge_tag', category: :general)
        PostTag.create!(post: post_record, tag: current_tag, created_user: member)

        PostVersionRecorder.record!(
            post: post_record.reload,
            event_type: :update,
            created_by_user: member)

        put "/posts/#{post_record.id}", params: post_write_params(
            base_version_no: base_version.version_no,
            title: post_record.title,
            tags: "spec_tag #{Tag.no_deerjikist.name} incoming_merge_tag",
            merge: '1')

        expect(response).to have_http_status(:ok)

        names = post_record.reload.tags.map(&:name)

        expect(names).to include('spec_tag')
        expect(names).to include(Tag.no_deerjikist.name)
        expect(names).to include('current_merge_tag')
        expect(names).to include('incoming_merge_tag')
      end

      it 'does not conflict when only nico tags changed after the base version' do
        sign_in_as(member)

        base_version = create_post_version_for!(post_record.reload)

        nico_tag = create_nico_tag!('nico:optimistic_lock_nico')
        PostTag.create!(post: post_record, tag: nico_tag, created_user: member)

        PostVersionRecorder.record!(
            post: post_record.reload,
            event_type: :update,
            created_by_user: member)

        expect(post_record.reload.version_no).to eq(2)

        put "/posts/#{post_record.id}", params: post_write_params(
            base_version_no: base_version.version_no,
            title: post_record.title,
            tags: "spec_tag #{ Tag.no_deerjikist.name }")

        expect(response).to have_http_status(:ok)

        names = post_record.reload.tags.map(&:name)

        expect(names).to include('spec_tag')
        expect(names).to include(Tag.no_deerjikist.name)
        expect(names).to include(nico_tag.name)
      end

      it 'keeps nico tags even when they are not included in PUT tags' do
        sign_in_as(member)

        nico_tag = create_nico_tag!('nico:readonly_update_nico')
        PostTag.create!(post: post_record, tag: nico_tag, created_user: member)

        base_version = create_post_version_for!(post_record.reload)

        put "/posts/#{post_record.id}", params: post_write_params(
            base_version_no: base_version.version_no,
            title: 'updated title',
            tags: "spec_tag #{ Tag.no_deerjikist.name }")

        expect(response).to have_http_status(:ok)

        names = post_record.reload.tags.map(&:name)

        expect(names).to include('spec_tag')
        expect(names).to include(Tag.no_deerjikist.name)
        expect(names).to include(nico_tag.name)
      end

      it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
        sign_in_as(member)

        nico_tag = create_nico_tag!('nico:relation_source')
        linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)

        NicoTagRelation.create!(nico_tag:, tag: linked_tag)
        PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
        PostTag.create!(post: post_record, tag: linked_tag, created_user: member)

        base_version = create_post_version_for!(post_record.reload)

        put "/posts/#{post_record.id}", params: post_write_params(
            base_version_no: base_version.version_no,
            title: post_record.title,
            tags: "spec_tag #{ Tag.no_deerjikist.name }")

        expect(response).to have_http_status(:ok)

        names = post_record.reload.tags.map(&:name)

        expect(names).to include(nico_tag.name)
        expect(names).to include('spec_tag')
        expect(names).to include(Tag.no_deerjikist.name)
        expect(names).not_to include(linked_tag.name)
      end

      it 'force-updates stale posts without base_version_no' do
        sign_in_as(member)

        create_post_version_for!(post_record.reload)

        post_record.update!(title: 'updated by other user')
        PostVersionRecorder.record!(
            post: post_record.reload,
            event_type: :update,
            created_by_user: member)

        put "/posts/#{post_record.id}", params: post_write_params(
            title: 'forced title',
            tags: "spec_tag #{Tag.no_deerjikist.name}",
            force: '1')

        expect(response).to have_http_status(:ok)
        expect(post_record.reload.title).to eq('forced title')
      end
    end
  end

  describe 'GET /posts/random' do
    it '404 when no posts' do
      PostTag.delete_all
      Post.delete_all
      get '/posts/random'
      expect(response).to have_http_status(:not_found)
    end

    it '200 and returns viewed boolean' do
      get '/posts/random'
      expect(response).to have_http_status(:ok)
      expect(json).to have_key('viewed')
      expect([true, false]).to include(json['viewed'])
    end
  end

  describe 'GET /posts/changes' do
    let(:member) { create(:user, :member) }

    it 'returns add/remove events (history) for a post' do
      # add
      tn2 = TagName.create!(name: 'spec_tag2')
      tag2 = Tag.create!(tag_name: tn2, category: :general)
      pt = PostTag.create!(post: post_record, tag: tag2, created_user: member)

      # remove (discard)
      pt.discard_by!(member)

      get '/posts/changes', params: { id: post_record.id }

      expect(response).to have_http_status(:ok)
      expect(json).to include('changes', 'count')
      expect(json['changes']).to be_an(Array)
      expect(json['count']).to be >= 2

      types = json['changes'].map { |e| e['change_type'] }.uniq
      expect(types).to include('add')
      expect(types).to include('remove')
    end

    it 'filters history by tag' do
      tn2 = TagName.create!(name: 'history_tag_hit')
      tag2 = Tag.create!(tag_name: tn2, category: :general)

      tn3 = TagName.create!(name: 'history_tag_miss')
      tag3 = Tag.create!(tag_name: tn3, category: :general)

      other_post = Post.create!(
        title: 'other post',
        url: 'https://example.com/history-other'
      )

      # hit: add
      PostTag.create!(post: post_record, tag: tag2, created_user: member)

      # hit: add + remove
      pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
      pt2.discard_by!(member)

      # miss: add + remove
      pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
      pt3.discard_by!(member)

      get '/posts/changes', params: { tag: tag2.id }

      expect(response).to have_http_status(:ok)
      expect(json).to include('changes', 'count')
      expect(json['count']).to eq(3)

      changes = json.fetch('changes')

      expect(changes.map { |e| e.dig('tag', 'id') }.uniq).to eq([tag2.id])
      expect(changes.map { |e| e['change_type'] }).to match_array(%w[add add remove])
      expect(changes.map { |e| e.dig('post', 'id') }).to match_array([
        post_record.id,
        other_post.id,
        other_post.id
      ])
    end

    it 'filters history by post and tag together' do
      tn2 = TagName.create!(name: 'history_tag_combo_hit')
      tag2 = Tag.create!(tag_name: tn2, category: :general)

      tn3 = TagName.create!(name: 'history_tag_combo_miss')
      tag3 = Tag.create!(tag_name: tn3, category: :general)

      other_post = Post.create!(
        title: 'other combo post',
        url: 'https://example.com/history-combo-other'
      )

      # hit
      PostTag.create!(post: post_record, tag: tag2, created_user: member)

      # miss by post
      pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
      pt2.discard_by!(member)

      # miss by tag
      pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
      pt3.discard_by!(member)

      get '/posts/changes', params: { id: post_record.id, tag: tag2.id }

      expect(response).to have_http_status(:ok)
      expect(json).to include('changes', 'count')
      expect(json['count']).to eq(1)

      changes = json.fetch('changes')
      expect(changes.size).to eq(1)
      expect(changes[0]['change_type']).to eq('add')
      expect(changes[0].dig('post', 'id')).to eq(post_record.id)
      expect(changes[0].dig('tag', 'id')).to eq(tag2.id)
    end

    it 'returns empty history when tag does not match' do
      tn2 = TagName.create!(name: 'history_tag_no_hit')
      tag2 = Tag.create!(tag_name: tn2, category: :general)

      get '/posts/changes', params: { tag: tag2.id }

      expect(response).to have_http_status(:ok)
      expect(json.fetch('changes')).to eq([])
      expect(json.fetch('count')).to eq(0)
    end
  end

  describe 'GET /posts/versions' do
    let(:member) { create(:user, :member, name: 'version member') }

    let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) }
    let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) }
    let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) }

    let(:oc_from) { Time.zone.local(2019, 12, 31, 0, 0, 0) }
    let(:oc_before) { Time.zone.local(2020, 1, 1, 0, 0, 0) }

    let!(:tag_name2) { TagName.create!(name: 'spec_tag_2') }
    let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :general) }

    def snapshot_tags(post)
      post.snapshot_tag_names.join(' ')
    end

    def create_post_version! post, version_no:, event_type:, created_by_user:, created_at:
      PostVersion.create!(
        post:,
        version_no:,
        event_type:,
        title: post.title,
        url: post.url,
        thumbnail_base: post.thumbnail_base,
        tags: snapshot_tags(post),
        parent_post_ids: post.snapshot_parent_post_ids.join(' '),
        original_created_from: post.original_created_from,
        original_created_before: post.original_created_before,
        created_at:,
        created_by_user:
      )
    end

    let!(:v1) do
      travel_to(t_v1) do
        create_post_version!(
          post_record,
          version_no: 1,
          event_type: 'create',
          created_by_user: member,
          created_at: t_v1
        )
      end
    end

    let!(:v2) do
      post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member)
      PostTag.create!(post: post_record, tag: tag2, created_user: member)
      post_record.update!(
        title: 'updated spec post',
        original_created_from: oc_from,
        original_created_before: oc_before
      )

      travel_to(t_v2) do
        create_post_version!(
          post_record.reload,
          version_no: 2,
          event_type: 'update',
          created_by_user: member,
          created_at: t_v2
        )
      end
    end

    let!(:other_post_version) do
      other_post = Post.create!(
        title: 'other versioned post',
        url: 'https://example.com/other-versioned'
      )
      PostTag.create!(post: other_post, tag: tag)

      travel_to(t_other) do
        create_post_version!(
          other_post,
          version_no: 1,
          event_type: 'create',
          created_by_user: member,
          created_at: t_other
        )
      end
    end

    it 'returns versions for the specified post in reverse chronological order' do
      get '/posts/versions', params: { post: post_record.id }

      expect(response).to have_http_status(:ok)
      expect(json).to include('versions', 'count')
      expect(json.fetch('count')).to eq(2)

      versions = json.fetch('versions')
      expect(versions.map { |v| v['post_id'] }.uniq).to eq([post_record.id])
      expect(versions.map { |v| v['version_no'] }).to eq([2, 1])

      latest = versions.first
      expect(latest).to include(
        'post_id' => post_record.id,
        'version_no' => 2,
        'event_type' => 'update',
        'created_by_user' => {
          'id' => member.id,
          'name' => member.name
        }
      )

      expect(latest.fetch('title')).to eq(
        'current' => 'updated spec post',
        'prev' => 'spec post'
      )
      expect(latest.fetch('url')).to eq(
        'current' => 'https://example.com/spec',
        'prev' => 'https://example.com/spec'
      )
      expect(latest.fetch('thumbnail')).to eq(
        'current' => nil,
        'prev' => nil
      )
      expect(latest.fetch('thumbnail_base')).to eq(
        'current' => nil,
        'prev' => nil
      )
      expect(latest.fetch('tags')).to include(
        { 'name' => 'spec_tag_2', 'type' => 'added' },
        { 'name' => 'spec_tag', 'type' => 'removed' }
      )
      expect(latest.fetch('original_created_from')).to eq(
        'current' => oc_from.iso8601,
        'prev' => nil
      )
      expect(latest.fetch('original_created_before')).to eq(
        'current' => oc_before.iso8601,
        'prev' => nil
      )
      expect(latest.fetch('created_at')).to eq(t_v2.iso8601)

      first = versions.second
      expect(first).to include(
        'post_id' => post_record.id,
        'version_no' => 1,
        'event_type' => 'create',
        'created_by_user' => {
          'id' => member.id,
          'name' => member.name
        }
      )
      expect(first.fetch('title')).to eq(
        'current' => 'spec post',
        'prev' => nil
      )
      expect(first.fetch('tags')).to include(
        { 'name' => 'spec_tag', 'type' => 'added' }
      )
      expect(first.fetch('created_at')).to eq(t_v1.iso8601)
    end

    it 'filters versions by tag when the current snapshot includes the tag' do
      get '/posts/versions', params: { post: post_record.id, tag: tag2.id }

      expect(response).to have_http_status(:ok)
      expect(json.fetch('count')).to eq(1)

      versions = json.fetch('versions')
      expect(versions.size).to eq(1)
      expect(versions[0]['post_id']).to eq(post_record.id)
      expect(versions[0]['version_no']).to eq(2)
      expect(versions[0]['tags']).to include(
        { 'name' => 'spec_tag_2', 'type' => 'added' }
      )
    end

    it 'filters versions by tag when the tag exists in either current or previous snapshot' do
      get '/posts/versions', params: { post: post_record.id, tag: tag.id }

      expect(response).to have_http_status(:ok)
      expect(json.fetch('count')).to eq(2)

      versions = json.fetch('versions')
      expect(versions.map { |v| v['post_id'] }).to all(eq(post_record.id))
      expect(versions.map { |v| v['version_no'] }).to eq([2, 1])

      latest = versions[0]
      first = versions[1]

      expect(latest['tags']).to include(
        { 'name' => 'spec_tag', 'type' => 'removed' }
      )
      expect(first['tags']).to include(
        { 'name' => 'spec_tag', 'type' => 'added' }
      )
    end

    it 'returns empty when tag does not exist' do
      get '/posts/versions', params: { tag: 999_999_999 }

      expect(response).to have_http_status(:ok)
      expect(json.fetch('versions')).to eq([])
      expect(json.fetch('count')).to eq(0)
    end

    it 'clamps page and limit to at least 1' do
      get '/posts/versions', params: { post: post_record.id, page: 0, limit: 0 }

      expect(response).to have_http_status(:ok)
      expect(json.fetch('count')).to eq(2)

      versions = json.fetch('versions')
      expect(versions.size).to eq(1)
      expect(versions[0]['version_no']).to eq(2)
    end
  end

  describe 'POST /posts/:id/viewed' do
    let(:user) { create(:user) }

    it '401 when not logged in' do
      sign_out
      post "/posts/#{ post_record.id }/viewed"
      expect(response).to have_http_status(:unauthorized)
    end

    it '204 and marks viewed when logged in' do
      sign_in_as(user)
      post "/posts/#{ post_record.id }/viewed"
      expect(response).to have_http_status(:no_content)

      expect(user.reload.viewed?(post_record)).to be(true)
    end
  end

  describe 'DELETE /posts/:id/viewed' do
    let(:user) { create(:user) }

    it '401 when not logged in' do
      sign_out
      delete "/posts/#{ post_record.id }/viewed"
      expect(response).to have_http_status(:unauthorized)
    end

    it '204 and unmarks viewed when logged in' do
      sign_in_as(user)

      # 先に viewed 付けてから外す
      user.viewed_posts << post_record

      delete "/posts/#{ post_record.id }/viewed"
      expect(response).to have_http_status(:no_content)

      expect(user.reload.viewed?(post_record)).to be(false)
    end
  end

  describe 'post versioning' do
    let(:member) { create(:user, :member) }

    def snapshot_tags(post)
      post.snapshot_tag_names.join(' ')
    end

    it 'creates version 1 on POST /posts' do
      sign_in_as(member)

      expect do
        post '/posts', params: post_write_params(
            title: 'versioned post',
            url: 'https://example.com/versioned-post',
            tags: 'spec_tag',
            thumbnail: dummy_upload)
      end.to change(PostVersion, :count).by(1)

      expect(response).to have_http_status(:created)

      created_post = Post.find(json.fetch('id'))
      version = PostVersion.find_by!(post: created_post, version_no: 1)

      expect(version.event_type).to eq('create')
      expect(version.title).to eq('versioned post')
      expect(version.url).to eq('https://example.com/versioned-post')
      expect(version.created_by_user_id).to eq(member.id)
      expect(version.tags).to eq(snapshot_tags(created_post))
    end

    it 'creates next version on PUT /posts/:id when snapshot changes' do
      sign_in_as(member)
      base_version = create_post_version_for!(post_record)

      tag_name2 = TagName.create!(name: 'spec_tag_2')
      Tag.create!(tag_name: tag_name2, category: :general)

      expect do
        put "/posts/#{post_record.id}", params: post_write_params(
            base_version_no: base_version.version_no,
            title: 'updated title',
            tags: 'spec_tag_2')
      end.to change(PostVersion, :count).by(1)

      expect(response).to have_http_status(:ok)

      version = post_record.reload.post_versions.order(:version_no).last
      expect(version.version_no).to eq(2)
      expect(version.event_type).to eq('update')
      expect(version.title).to eq('updated title')
      expect(version.created_by_user_id).to eq(member.id)
      expect(version.tags).to eq(snapshot_tags(post_record.reload))
    end

    it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do
      sign_in_as(member)

      PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
      base_version = create_post_version_for!(post_record.reload)

      expect {
        put "/posts/#{post_record.id}", params: post_write_params(
            base_version_no: base_version.version_no,
            title: post_record.title,
            tags: 'spec_tag')
      }.not_to change(PostVersion, :count)

      expect(response).to have_http_status(:ok)

      version = post_record.reload.post_versions.order(:version_no).last
      expect(version.version_no).to eq(1)
      expect(version.event_type).to eq('create')
      expect(version.tags).to eq(snapshot_tags(post_record))
    end

    it 'does not create a version when POST /posts is invalid' do
      sign_in_as(member)

      expect do
        post '/posts', params: post_write_params(
            title: 'invalid post',
            url: 'ぼざクリタグ広場',
            tags: 'spec_tag',
            thumbnail: dummy_upload)
      end.not_to change(PostVersion, :count)

      expect(response).to have_http_status(:unprocessable_entity)
    end

    it 'does not create a version when PUT /posts/:id is invalid' do
      sign_in_as(member)
      base_version = create_post_version_for!(post_record)

      expect do
        put "/posts/#{post_record.id}", params: post_write_params(
            base_version_no: base_version.version_no,
            title: 'updated title',
            tags: 'spec_tag',
            original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601,
            original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601)
      end.not_to change(PostVersion, :count)

      expect(response).to have_http_status(:unprocessable_entity)
    end

    it 'rejects second-bearing original created timestamps on POST /posts' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
          title: 'invalid original created from',
          url: 'https://example.com/invalid-original-created-from',
          tags: 'spec_tag',
          thumbnail: dummy_upload,
          original_created_from: '2020-01-01T00:00:01Z')

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json.fetch('errors')).to include(
        'original_created_from' => ['オリジナルの作成日時は分単位で入力してください．']
      )
      expect(json.fetch('errors')).not_to have_key('original_created_at')
    end

    it 'rejects fractional-second original created timestamps on POST /posts' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
          title: 'invalid original created before',
          url: 'https://example.com/invalid-original-created-before',
          tags: 'spec_tag',
          thumbnail: dummy_upload,
          original_created_before: '2020-01-01T00:00:00.123Z')

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json.fetch('errors')).to include(
        'original_created_before' => ['オリジナルの作成日時は分単位で入力してください．']
      )
      expect(json.fetch('errors')).not_to have_key('original_created_at')
    end

    it 'rejects non-increasing original created ranges on POST /posts' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
          title: 'non-increasing original created range',
          url: 'https://example.com/non-increasing-original-created-range',
          tags: 'spec_tag',
          thumbnail: dummy_upload,
          original_created_from: '2020-01-01T00:00Z',
          original_created_before: '2020-01-01T00:00Z')

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json.fetch('errors')).to include(
        'original_created_at' => ['オリジナルの作成日時の順番がをかしぃです．']
      )
    end

    it 'accepts original created ranges that are exactly one minute on POST /posts' do
      sign_in_as(member)

      post '/posts', params: post_write_params(
          title: 'valid original created range',
          url: 'https://example.com/valid-original-created-range',
          tags: 'spec_tag',
          thumbnail: dummy_upload,
          original_created_from: '2020-01-01T00:00Z',
          original_created_before: '2020-01-01T00:01Z')

      expect(response).to have_http_status(:created)
      expect(Time.iso8601(json.fetch('original_created_from')))
          .to eq(Time.utc(2020, 1, 1, 0, 0))
      expect(Time.iso8601(json.fetch('original_created_before')))
          .to eq(Time.utc(2020, 1, 1, 0, 1))
    end

    it 'rejects unparseable original created timestamps on PUT /posts/:id' do
      sign_in_as(member)
      base_version = create_post_version_for!(post_record)

      put "/posts/#{post_record.id}", params: post_write_params(
          base_version_no: base_version.version_no,
          title: 'updated title',
          tags: 'spec_tag',
          original_created_from: 'not-a-time')

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json.fetch('errors')).to include(
        'original_created_from' => ['オリジナルの作成日時の形式が不正です．']
      )
    end
  end

  describe 'tag versioning from post write actions' do
    let(:member) { create(:user, :member) }

    it 'creates tag snapshot for normalised tags on PUT /posts/:id' do
      sign_in_as(member)

      base_version = create_post_version_for!(post_record.reload)

      tag_name2 = TagName.create!(name: 'spec_tag_2')
      tag2 = Tag.create!(tag_name: tag_name2, category: :general)

      expect {
        put "/posts/#{post_record.id}", params: post_write_params(
            base_version_no: base_version.version_no,
            title: 'updated title',
            tags: 'spec_tag_2')
      }.to change { tag2.reload.tag_versions.count }.by(1)

      expect(response).to have_http_status(:ok), response.body
    end
  end
end
