require 'cgi'
require 'rails_helper'

RSpec.describe 'Tags API', type: :request do
  let!(:tn) { TagName.create!(name: 'spec_tag') }
  let!(:tag) { Tag.create!(tag_name: tn, category: :general) }
  let!(:alias_tn) { TagName.create!(name: 'unko', canonical: tn) }
  let!(:post) { Post.create!(url: 'https://example.com/unkounkounko') }
  let!(:post_tag) { PostTag.create!(post:, tag:) }
  let!(:tn2) { TagName.create!(name: 'unknown') }
  let!(:tag2) { Tag.create!(tag_name: tn2, category: :general) }

  def response_tags
    json.fetch('tags')
  end

  def response_names
    response_tags.map { |t| t.fetch('name') }
  end

  def dummy_material_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
    Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
  end

  def create_material(tag, user:, filename: 'dummy.png', type: 'image/png', url: nil)
    Material.new(tag:, url:, created_by_user: user, updated_by_user: user).tap do |material|
      material.file.attach(dummy_material_upload(filename:, type:)) if filename
      material.save!
    end
  end

  describe 'GET /tags' do
    it 'returns tags with count and metadata' do
      get '/tags'

      expect(response).to have_http_status(:ok)
      expect(json).to include('tags', 'count')
      expect(response_tags).to be_an(Array)
      expect(json['count']).to be_an(Integer)
      expect(json['count']).to be >= response_tags.size

      row = response_tags.find { |t| t['name'] == 'spec_tag' }
      expect(row).to include(
          'id' => tag.id,
          'name' => 'spec_tag',
          'category' => 'general',
          'post_count' => 1,
          'has_wiki' => false)
      expect(row).to have_key('created_at')
      expect(row).to have_key('updated_at')
    end

    it 'filters tags by post id' do
      get '/tags', params: { post: post.id }

      expect(response).to have_http_status(:ok)
      expect(json['count']).to eq(1)
      expect(response_names).to eq(['spec_tag'])
    end

    it 'filters tags by partial name' do
      get '/tags', params: { name: 'spec' }

      expect(response).to have_http_status(:ok)
      expect(response_names).to include('spec_tag')
      expect(response_names).not_to include('unknown')
    end

    it 'filters tags by category' do
      meme = Tag.create!(tag_name: TagName.create!(name: 'meme_only'), category: :meme)

      get '/tags', params: { category: 'meme' }

      expect(response).to have_http_status(:ok)
      expect(response_names).to eq(['meme_only'])
      expect(response_tags.first['id']).to eq(meme.id)
    end

    it 'filters tags by deprecated state' do
      deprecated_tag = Tag.create!(
        name: 'deprecated_filter',
        category: :general,
        deprecated_at: 1.day.from_now
      )
      active_tag = Tag.create!(name: 'active_filter', category: :general)

      get '/tags', params: { name: '_filter', deprecated: '1' }

      expect(response).to have_http_status(:ok)
      expect(response_names).to include(deprecated_tag.name)
      expect(response_names).not_to include(active_tag.name)

      get '/tags', params: { name: '_filter', deprecated: '0' }

      expect(response).to have_http_status(:ok)
      expect(response_names).to include(active_tag.name)
      expect(response_names).not_to include(deprecated_tag.name)
    end

    it 'filters tags by post_count range' do
      low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general)
      mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general)
      high = Tag.create!(tag_name: TagName.create!(name: 'pc_high'), category: :general)

      low.update_columns(post_count: 1)
      mid.update_columns(post_count: 3)
      high.update_columns(post_count: 5)

      get '/tags', params: {
          name: 'pc_',
          post_count_gte: 2,
          post_count_lte: 4,
          order: 'post_count:asc',
      }

      expect(response).to have_http_status(:ok)
      expect(response_names).to eq(['pc_mid'])
    end

    it 'filters tags by created_at range' do
      old_tag = Tag.create!(tag_name: TagName.create!(name: 'created_old'), category: :general)
      new_tag = Tag.create!(tag_name: TagName.create!(name: 'created_new'), category: :general)

      old_time = Time.zone.local(2024, 1, 1, 0, 0, 0)
      new_time = Time.zone.local(2024, 2, 1, 0, 0, 0)

      old_tag.update_columns(created_at: old_time, updated_at: old_time)
      new_tag.update_columns(created_at: new_time, updated_at: new_time)

      get '/tags', params: {
          name: 'created_',
          created_from: Time.zone.local(2024, 1, 15, 0, 0, 0).iso8601,
          order: 'created_at:asc',
      }

      expect(response).to have_http_status(:ok)
      expect(response_names).to eq(['created_new'])
    end

    it 'filters tags by updated_at range' do
      old_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_old'), category: :general)
      new_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_new'), category: :general)

      old_time = Time.zone.local(2024, 3, 1, 0, 0, 0)
      new_time = Time.zone.local(2024, 4, 1, 0, 0, 0)

      old_tag.update_columns(created_at: old_time, updated_at: old_time)
      new_tag.update_columns(created_at: new_time, updated_at: new_time)

      get '/tags', params: {
          name: 'updated_',
          updated_to: Time.zone.local(2024, 3, 15, 0, 0, 0).iso8601,
          order: 'updated_at:asc',
      }

      expect(response).to have_http_status(:ok)
      expect(response_names).to eq(['updated_old'])
    end

    it 'orders tags by custom category order' do
      Tag.create!(tag_name: TagName.create!(name: 'cat_deerjikist'), category: :deerjikist)
      Tag.create!(tag_name: TagName.create!(name: 'cat_meme'), category: :meme)
      Tag.create!(tag_name: TagName.create!(name: 'cat_character'), category: :character)
      Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general)
      Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material)
      Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta)
      Tag.create!(tag_name: TagName.create!(name: 'nico:cat_nico'), category: :nico)

      get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 }

      expect(response).to have_http_status(:ok)
      expect(response_names).to eq(%w[
          cat_deerjikist
          cat_meme
          cat_character
          cat_general
          cat_material
          cat_meta
          nico:cat_nico
      ])
    end

    it 'paginates and keeps total count' do
      %w[pag_a pag_b pag_c].each do |name|
        Tag.create!(tag_name: TagName.create!(name:), category: :general)
      end

      get '/tags', params: { name: 'pag_', order: 'name:asc', page: 2, limit: 2 }

      expect(response).to have_http_status(:ok)
      expect(json['count']).to eq(3)
      expect(response_names).to eq(%w[pag_c])
    end

    it 'falls back to default ordering when order is invalid' do
      low = Tag.create!(tag_name: TagName.create!(name: 'fallback_low'), category: :general)
      high = Tag.create!(tag_name: TagName.create!(name: 'fallback_high'), category: :general)

      low.update_columns(post_count: 1)
      high.update_columns(post_count: 9)

      get '/tags', params: { name: 'fallback_', order: 'nope:sideways' }

      expect(response).to have_http_status(:ok)
      expect(response_names.first).to eq('fallback_high')
    end

    it 'normalises invalid page and limit' do
      %w[norm_a norm_b].each do |name|
        Tag.create!(tag_name: TagName.create!(name:), category: :general)
      end

      get '/tags', params: { name: 'norm_', order: 'name:asc', page: 0, limit: 0 }

      expect(response).to have_http_status(:ok)
      expect(json['count']).to eq(2)
      expect(response_tags.size).to eq(1)
      expect(response_names).to eq(['norm_a'])
    end

    it 'returns aliases and parent tags' do
      parent_tag = Tag.create!(
        tag_name: TagName.create!(name: 'index_parent_tag'),
        category: :meme
      )
      TagImplication.create!(tag:, parent_tag:)

      get '/tags', params: { name: 'spec_tag' }

      expect(response).to have_http_status(:ok)

      row = response_tags.find { |t| t['name'] == 'spec_tag' }

      expect(row['aliases']).to include('unko')
      expect(row['parents'].map { |t| t['name'] }).to include('index_parent_tag')

      parent = row['parents'].find { |t| t['name'] == 'index_parent_tag' }
      expect(parent).to include(
        'id' => parent_tag.id,
        'name' => 'index_parent_tag',
        'category' => 'meme'
      )
    end
  end

  describe 'GET /tags/:id' do
    subject(:request) do
      get "/tags/#{ tag_id }"
    end

    let(:tag_id) { tag.id }

    context 'when tag exists' do
      it 'returns tag with name' do
        request
        expect(response).to have_http_status(:ok)

        expect(json).to include(
            'id' => tag.id,
            'name' => 'spec_tag',
            'category' => 'general',
            'post_count' => 1,
            'has_wiki' => false)
        expect(json).to have_key('created_at')
        expect(json).to have_key('updated_at')
      end

      it 'returns aliases and parent tags' do
        parent_tag = Tag.create!(
          tag_name: TagName.create!(name: 'show_parent_tag'),
          category: :character
        )
        TagImplication.create!(tag:, parent_tag:)

        request

        expect(response).to have_http_status(:ok)

        expect(json['aliases']).to include('unko')
        expect(json['parents'].map { |t| t['name'] }).to include('show_parent_tag')

        parent = json['parents'].find { |t| t['name'] == 'show_parent_tag' }
        expect(parent).to include(
          'id' => parent_tag.id,
          'name' => 'show_parent_tag',
          'category' => 'character'
        )
      end
    end

    context 'when tag does not exist' do
      let(:tag_id) { 9_999_999 }

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

  describe 'GET /tags/autocomplete' do
    it 'returns matching tags by q' do
      get '/tags/autocomplete', params: { q: 'spec' }

      expect(response).to have_http_status(:ok)

      expect(json).to be_an(Array)
      expect(json.map { |t| t['name'] }).to include('spec_tag')
      t = json.find { |x| x['name'] == 'spec_tag' }
      expect(t).to have_key('matched_alias')
      expect(t['matched_alias']).to be(nil)
    end

    it 'returns matching canonical tags by q with aliases' do
      get '/tags/autocomplete', params: { q: 'unk' }

      expect(response).to have_http_status(:ok)

      expect(json).to be_an(Array)
      expect(json.map { |t| t['name'] }).to include('spec_tag')
      t = json.find { |x| x['name'] == 'spec_tag' }
      expect(t['matched_alias']).to eq('unko')
      expect(json.map { |x| x['name'] }).not_to include('unknown')
    end

    it 'omits deprecated tags' do
      deprecated_tag = Tag.create!(
        name: 'spec_deprecated',
        category: :general,
        deprecated_at: Time.current
      )
      deprecated_tag.update_columns(post_count: 1)

      get '/tags/autocomplete', params: { q: 'spec_', present: '0' }

      expect(response).to have_http_status(:ok)
      expect(json.map { |item| item.fetch('name') }).to include('spec_tag')
      expect(json.map { |item| item.fetch('name') }).not_to include('spec_deprecated')
    end
  end

  describe 'GET /tags/name/:name' do
    it 'returns tag by name' do
      get "/tags/name/#{ CGI.escape('spec_tag') }"

      expect(response).to have_http_status(:ok)

      expect(json).to include(
          'id' => tag.id,
          'name' => 'spec_tag',
          'category' => 'general',
          'post_count' => 1,
          'has_wiki' => false)
      expect(json).to have_key('created_at')
      expect(json).to have_key('updated_at')
    end

    it 'returns 404 when not found' do
      get "/tags/name/#{ CGI.escape('nope') }"
      expect(response).to have_http_status(:not_found)
    end
  end

  let(:member_user) { create(:user) }
  let(:non_member_user) { create(:user) }

  def stub_current_user(user)
    allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
  end

  before do
    allow(member_user).to receive(:gte_member?).and_return(true)
    allow(non_member_user).to receive(:gte_member?).and_return(false)
  end

  describe 'PATCH /tags/:id' do
    context '未ログイン' do
      before { stub_current_user(nil) }

      it '401 を返す' do
        patch "/tags/#{ tag.id }", params: { name: 'new' }
        expect(response).to have_http_status(:unauthorized)
      end
    end

    context 'ログインしてゐるが member でない' do
      before { stub_current_user(non_member_user) }

      it '403 を返す' do
        patch "/tags/#{ tag.id }", params: { name: 'new' }
        expect(response).to have_http_status(:forbidden)
      end
    end

    context 'member' do
      before { stub_current_user(member_user) }

      it 'name だけ更新できる' do
        patch "/tags/#{ tag.id }", params: { name: 'new' }

        expect(response).to have_http_status(:ok)

        tag.reload
        expect(tag.name).to eq('new')
        expect(tag.category).to eq('general')

        body = JSON.parse(response.body)
        expect(body['id']).to eq(tag.id)
        expect(body['name']).to eq('new')
        expect(body['category']).to eq('general')
      end

      it 'category だけ更新できる' do
        patch "/tags/#{ tag.id }", params: { category: 'meme' }

        expect(response).to have_http_status(:ok)

        tag.reload
        expect(tag.name).to eq('spec_tag')
        expect(tag.category).to eq('meme')
      end

      it '空文字は presence により無視され、更新は走らない（値が変わらない）' do
        patch "/tags/#{ tag.id }", params: { name: '', category: '   ' }

        expect(response).to have_http_status(:ok)

        tag.reload
        expect(tag.name).to eq('spec_tag')
        expect(tag.category).to eq('general')
      end

      it '両方更新できる' do
        patch "/tags/#{ tag.id }", params: { name: 'n', category: 'meta' }

        expect(response).to have_http_status(:ok)

        tag.reload
        expect(tag.name).to eq('n')
        expect(tag.category).to eq('meta')
      end

      it '存在しない id なら 404 を返す' do
        patch '/tags/999999999', params: { name: 'x' }
        expect(response).to have_http_status(:not_found)
      end

      it 'nico category への変更は 422 を返す' do
        patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' }

        expect(response).to have_http_status(:unprocessable_entity)
        expect(tag.reload.name).to eq('spec_tag')
        expect(tag.category).to eq('general')
      end

      it 'creates initial and update tag versions when name and category change' do
        expect {
          patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' }
        }.to change(TagVersion, :count).by(2)

        expect(response).to have_http_status(:ok)

        versions = tag.reload.tag_versions.order(:version_no)

        expect(versions.map(&:event_type)).to eq(['create', 'update'])

        expect(versions.first.name).to eq('spec_tag')
        expect(versions.first.category).to eq('general')
        expect(versions.first.aliases.split).to include('unko')

        expect(versions.second.name).to eq('new_tag_name')
        expect(versions.second.category).to eq('meme')
        expect(versions.second.created_by_user_id).to eq(member_user.id)
      end

      it 'updates deprecated state and records it in tag versions' do
        expect {
          patch "/tags/#{ tag.id }", params: { deprecated: '1' }
        }.to change(TagVersion, :count).by(2)

        expect(response).to have_http_status(:ok)
        expect(tag.reload.deprecated_at).to be_present

        versions = tag.tag_versions.order(:version_no)
        expect(versions.first.deprecated_at).to be_nil
        expect(versions.second.deprecated_at).to eq(tag.deprecated_at)
        expect(json.fetch('deprecated_at')).to be_present
      end

      it 'rejects deprecating a nico tag' do
        nico_tag = Tag.create!(name: 'nico:deprecated_update', category: :nico)

        patch "/tags/#{ nico_tag.id }", params: { deprecated: '1' }

        expect(response).to have_http_status(:unprocessable_entity)
        expect(nico_tag.reload.deprecated_at).to be_nil
        expect(json.fetch('errors')).to include(
          'deprecated' => ['ニコタグは廃止できません．']
        )
      end

      it 'returns 422 when changing normal tag category to nico' do
        expect {
          patch "/tags/#{tag.id}", params: { category: 'nico' }
        }.not_to change(TagVersion, :count)

        expect(response).to have_http_status(:unprocessable_entity)
        expect(tag.reload.category).to eq('general')
      end

      it 'returns 422 when updating nico tag name' do
        nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
        nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)

        expect {
          patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' }
        }.not_to change(NicoTagVersion, :count)

        expect(response).to have_http_status(:unprocessable_entity)

        expect(nico_tag.reload.name).to eq('nico:tags_spec_source')
        expect(nico_tag.category).to eq('nico')
      end

      it 'returns 422 when changing nico tag category to normal category' do
        nico_tag_name = TagName.create!(name: 'nico:category_change_ng')
        nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)

        expect {
          patch "/tags/#{nico_tag.id}", params: { category: 'general' }
        }.not_to change(NicoTagVersion, :count)

        expect(response).to have_http_status(:unprocessable_entity)
        expect(nico_tag.reload.category).to eq('nico')
      end

      it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
        wiki_page =
            Wiki::Commit.create_content!(
                tag_name: tag.tag_name,
                body: 'wiki body before',
                created_by_user: member_user,
                message: 'init')

        expect {
          patch "/tags/#{ tag.id }", params: {
            name: 'patch_wiki_renamed_tag',
          }
        }
            .to change(TagVersion, :count).by(2)
            .and change(WikiVersion, :count).by(1)

        expect(response).to have_http_status(:ok)

        version = wiki_page.reload.wiki_versions.order(:version_no).last

        expect(version).to have_attributes(
          event_type: 'update',
          title: 'patch_wiki_renamed_tag',
          body: 'wiki body before',
          created_by_user_id: member_user.id
        )
      end

      it 'tag の category だけを変更しても wiki version は作成しない' do
        wiki_page =
            Wiki::Commit.create_content!(
                tag_name: tag.tag_name,
                body: 'wiki body before',
                created_by_user: member_user,
                message: 'init')

        before_wiki_version_count = wiki_page.reload.wiki_versions.count

        expect {
          patch "/tags/#{ tag.id }", params: {
            category: 'meme',
          }
        }.to change(TagVersion, :count).by(2)

        expect(response).to have_http_status(:ok)
        expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
      end

      it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do
        put "/tags/#{ tag.id }", params: {
          name: 'patch_roundtrip_target',
          category: 'general',
          aliases: 'unko',
          parent_tags: '',
          deprecated: '0',
        }

        expect(response).to have_http_status(:ok)
        expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.reload.tag_name)

        patch "/tags/#{ tag.id }", params: { name: 'spec_tag' }

        expect(response).to have_http_status(:ok)

        tag.reload

        expect(tag.name).to eq('spec_tag')
        expect(tag.tag_name.canonical_id).to be_nil
        expect(TagName.find_by!(name: 'patch_roundtrip_target').canonical).to eq(tag.tag_name)
      end

      it '別 tag の正規名には変更できない' do
        wiki_page =
            Wiki::Commit.create_content!(
                tag_name: tag.tag_name,
                body: 'patch collision wiki',
                created_by_user: member_user,
                message: 'init')

        patch "/tags/#{ tag.id }", params: { name: 'unknown' }

        expect(response).to have_http_status(:unprocessable_entity)
        expect(json.fetch('errors')).to include(
          'name' => ['その名前は既に使はれてゐます．']
        )

        expect(tag.reload.name).to eq('spec_tag')
        expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('unko')
        expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
      end

      it 'system tag の name は変更できない' do
        system_tag = Tag.bot

        patch "/tags/#{ system_tag.id }", params: { name: 'patch_system_tag_renamed' }

        expect(response).to have_http_status(:unprocessable_entity)
        expect(system_tag.reload.name).to eq('bot操作')
      end
    end
  end

  describe 'GET /tags/with-depth' do
    let!(:root_meme) do
      Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme)
    end

    let!(:root_material) do
      Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material)
    end

    let!(:hidden_general_root) do
      Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general)
    end

    let!(:child_character) do
      Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character)
    end

    let!(:grandchild_material) do
      Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material)
    end

    let!(:child_general) do
      Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general)
    end

    before do
      TagImplication.create!(parent_tag: root_meme, tag: child_character)
      TagImplication.create!(parent_tag: child_character, tag: grandchild_material)
      TagImplication.create!(parent_tag: root_material, tag: child_general)
    end

    it 'returns only visible root tags and visible has_children flags' do
      get '/tags/with-depth'

      expect(response).to have_http_status(:ok)
      expect(json.map { |t| t['name'] }).to eq([
        'depth_a_root_meme',
        'depth_b_root_material'
      ])

      meme_row = json.find { |t| t['name'] == 'depth_a_root_meme' }
      material_row = json.find { |t| t['name'] == 'depth_b_root_material' }

      expect(meme_row['has_children']).to eq(true)
      expect(meme_row['children']).to eq([])

      expect(material_row['has_children']).to eq(false)
      expect(material_row['children']).to eq([])

      expect(json.map { |t| t['name'] }).not_to include('depth_hidden_general_root')
    end

    it 'returns children of the specified parent' do
      get '/tags/with-depth', params: { parent: root_meme.id }

      expect(response).to have_http_status(:ok)
      expect(json.map { |t| t['name'] }).to eq(['depth_child_character'])

      row = json.first
      expect(row['category']).to eq('character')
      expect(row['has_children']).to eq(true)
      expect(row['children']).to eq([])
    end

    it 'passes through deprecated tags when finding children' do
      deprecated_middle = Tag.create!(
        name: 'depth_deprecated_middle',
        category: :character,
        deprecated_at: Time.current
      )
      visible_descendant = Tag.create!(
        name: 'depth_visible_descendant',
        category: :material
      )
      TagImplication.create!(parent_tag: root_material, tag: deprecated_middle)
      TagImplication.create!(parent_tag: deprecated_middle, tag: visible_descendant)

      get '/tags/with-depth', params: { parent: root_material.id }

      expect(response).to have_http_status(:ok)
      expect(json.map { |item| item.fetch('name') }).to eq(['depth_visible_descendant'])
      expect(json.map { |item| item.fetch('name') }).not_to include('depth_deprecated_middle')
    end

    it 'passes through multiple deprecated tags for roots and has_children' do
      active_child = Tag.create!(
        name: 'depth_active_child_below_deprecated',
        category: :character
      )
      deprecated_parent = Tag.create!(
        name: 'depth_deprecated_parent',
        category: :character,
        deprecated_at: Time.current
      )
      deprecated_grandparent = Tag.create!(
        name: 'depth_deprecated_grandparent',
        category: :material,
        deprecated_at: Time.current
      )
      active_ancestor = Tag.create!(
        name: 'depth_active_ancestor',
        category: :meme
      )
      TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
      TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
      TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)

      get '/tags/with-depth'

      root_names = json.map { |item| item.fetch('name') }
      expect(root_names).to include('depth_active_ancestor')
      expect(root_names).not_to include('depth_active_child_below_deprecated')
      ancestor_json = json.find { |item| item.fetch('id') == active_ancestor.id }
      expect(ancestor_json.fetch('has_children')).to eq(true)

      get '/tags/with-depth', params: { parent: active_ancestor.id }

      expect(json.map { |item| item.fetch('name') }).to include(
        'depth_active_child_below_deprecated'
      )
      expect(json.map { |item| item.fetch('name') }).not_to include(
        'depth_deprecated_parent',
        'depth_deprecated_grandparent'
      )
    end

    it 'treats an active tag with only deprecated ancestors as a root' do
      active_child = Tag.create!(
        name: 'depth_root_below_deprecated',
        category: :character
      )
      deprecated_parent = Tag.create!(
        name: 'depth_root_deprecated_parent',
        category: :material,
        deprecated_at: Time.current
      )
      TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)

      get '/tags/with-depth'

      expect(json.map { |item| item.fetch('name') }).to include(
        'depth_root_below_deprecated'
      )
      expect(json.map { |item| item.fetch('name') }).not_to include(
        'depth_root_deprecated_parent'
      )
    end

    it 'terminates when deprecated implications contain a cycle' do
      first = Tag.create!(
        name: 'depth_cycle_first',
        category: :character,
        deprecated_at: Time.current
      )
      second = Tag.create!(
        name: 'depth_cycle_second',
        category: :material,
        deprecated_at: Time.current
      )
      TagImplication.create!(tag: first, parent_tag: root_material)
      TagImplication.create!(tag: second, parent_tag: first)
      now = Time.current
      TagImplication.insert_all!(
        [
          {
            tag_id: first.id,
            parent_tag_id: second.id,
            created_at: now,
            updated_at: now
          }
        ]
      )

      get '/tags/with-depth', params: { parent: root_material.id }

      expect(response).to have_http_status(:ok)
      expect(json).to eq([])
    end
  end

  describe 'GET /tags/name/:name/materials' do
    let!(:material_user) { create_member_user! }

    let!(:root_tag) do
      Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material)
    end

    let!(:child_a_tag) do
      Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material)
    end

    let!(:child_b_tag) do
      Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character)
    end

    let!(:grandchild_tag) do
      Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material)
    end

    let!(:root_material) do
      create_material(root_tag, user: material_user, filename: 'root.png')
    end

    let!(:child_a_material) do
      create_material(child_a_tag, user: material_user, filename: 'child_a.png')
    end

    let!(:grandchild_material) do
      create_material(grandchild_tag, user: material_user, filename: 'grandchild.png')
    end

    before do
      TagImplication.create!(parent_tag: root_tag, tag: child_b_tag)
      TagImplication.create!(parent_tag: root_tag, tag: child_a_tag)
      TagImplication.create!(parent_tag: child_a_tag, tag: grandchild_tag)
    end

    it 'returns a tag tree with nested materials sorted by child name' do
      get "/tags/name/#{ CGI.escape(root_tag.name) }/materials"

      expect(response).to have_http_status(:ok)

      expect(json).to include(
        'id' => root_tag.id,
        'name' => 'materials_root',
        'category' => 'material'
      )

      expect(json['material']).to be_present
      expect(json.dig('material', 'id')).to eq(root_material.id)
      expect(json.dig('material', 'file')).to be_present
      expect(json.dig('material', 'content_type')).to eq('image/png')

      expect(json['children'].map { |t| t['name'] }).to eq([
        'materials_child_a',
        'materials_child_b'
      ])

      child_a = json['children'].find { |t| t['name'] == 'materials_child_a' }
      child_b = json['children'].find { |t| t['name'] == 'materials_child_b' }

      expect(child_a.dig('material', 'id')).to eq(child_a_material.id)
      expect(child_a['children'].map { |t| t['name'] }).to eq(['materials_grandchild'])
      expect(child_a.dig('children', 0, 'material', 'id')).to eq(grandchild_material.id)

      expect(child_b['material']).to be_nil
      expect(child_b['children']).to eq([])
    end

    it 'returns 404 when the tag does not exist' do
      get '/tags/name/no_such_tag_12345/materials'
      expect(response).to have_http_status(:not_found)
    end
  end

  describe 'PUT /tags/:id' do
    context '未ログイン' do
      before { stub_current_user(nil) }

      it '401 を返す' do
        put "/tags/#{ tag.id }", params: {
          name: 'new',
          category: 'general',
          aliases: '',
          parent_tags: '',
        }

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

    context 'ログインしてゐるが member でない' do
      before { stub_current_user(non_member_user) }

      it '403 を返す' do
        put "/tags/#{ tag.id }", params: {
          name: 'new',
          category: 'general',
          aliases: '',
          parent_tags: '',
        }

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

    context 'member' do
      before { stub_current_user(member_user) }

      it '存在しない id なら 404 を返す' do
        put '/tags/999999999', params: {
          name: 'new',
          category: 'general',
          aliases: '',
          parent_tags: '',
        }

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

      it 'name が空なら 422 を返す' do
        put "/tags/#{ tag.id }", params: {
          name: '',
          category: 'general',
          aliases: '',
          parent_tags: '',
        }

        expect(response).to have_http_status(:unprocessable_entity)
        expect(tag.reload.name).to eq('spec_tag')
      end

      it 'category が空なら 422 を返す' do
        put "/tags/#{ tag.id }", params: {
          name: 'new',
          category: '',
          aliases: '',
          parent_tags: '',
        }

        expect(response).to have_http_status(:unprocessable_entity)
        expect(tag.reload.name).to eq('spec_tag')
        expect(tag.category).to eq('general')
      end

      it 'deprecated がなければ 422 を返す' do
        put "/tags/#{ tag.id }", params: {
          name: 'new',
          category: 'general',
          aliases: '',
          parent_tags: '',
        }

        expect(response).to have_http_status(:unprocessable_entity)
        expect(json.fetch('errors')).to include(
          'deprecated' => ['廃止状態は必須です．']
        )
      end

      it 'name, category, aliases, parent tags をまとめて更新できる' do
        old_parent = Tag.create!(
          tag_name: TagName.create!(name: 'put_old_parent'),
          category: :general
        )
        kept_parent = Tag.create!(
          tag_name: TagName.create!(name: 'put_kept_parent'),
          category: :general
        )
        TagImplication.create!(tag:, parent_tag: old_parent)
        TagImplication.create!(tag:, parent_tag: kept_parent)

        put "/tags/#{ tag.id }", params: {
          name: 'put_renamed_tag',
          category: 'meme',
          aliases: 'put_alias_a put_alias_b put_alias_a',
          parent_tags: 'put_kept_parent put_new_parent',
          deprecated: '0',
        }

        expect(response).to have_http_status(:ok)

        tag.reload

        expect(tag.name).to eq('put_renamed_tag')
        expect(tag.category).to eq('meme')

        expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name)
        expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name)

        old_name_alias = TagName.find_by(name: 'spec_tag')
        expect(old_name_alias).to be_present
        expect(old_name_alias.canonical).to eq(tag.tag_name)

        expect(alias_tn.reload.canonical).to be_nil

        expect(tag.parents.map(&:name)).to contain_exactly(
          'put_kept_parent',
          'put_new_parent'
        )

        expect(TagImplication.where(tag:, parent_tag: old_parent)).not_to exist

        expect(json['name']).to eq('put_renamed_tag')
        expect(json['category']).to eq('meme')
        expect(json['aliases']).to contain_exactly(
          'put_alias_a',
          'put_alias_b',
          'spec_tag'
        )
        expect(json['parents'].map { |t| t['name'] }).to contain_exactly(
          'put_kept_parent',
          'put_new_parent'
        )
      end

      it 'aliases に現在名を指定しても alias には残さない' do
        put "/tags/#{ tag.id }", params: {
          name: 'spec_tag',
          category: 'general',
          aliases: 'spec_tag put_alias_self_test',
          parent_tags: '',
          deprecated: '0',
        }

        expect(response).to have_http_status(:ok)

        tag.reload

        expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name)
        expect(json['aliases']).to include('put_alias_self_test')
        expect(json['aliases']).not_to include('spec_tag')
      end

      it 'parent_tags に自分自身を指定しても自己参照は作らない' do
        put "/tags/#{ tag.id }", params: {
          name: 'spec_tag',
          category: 'general',
          aliases: 'unko',
          parent_tags: 'spec_tag',
          deprecated: '0',
        }

        expect(response).to have_http_status(:ok)

        expect(TagImplication.where(tag:, parent_tag: tag)).not_to exist
        expect(tag.reload.parents).to eq([])
      end

      it 'initial and update tag versions を作成する' do
        expect {
          put "/tags/#{ tag.id }", params: {
            name: 'put_versioned_tag',
            category: 'meta',
            aliases: '',
            parent_tags: '',
            deprecated: '0',
          }
        }.to change(TagVersion, :count).by(2)

        expect(response).to have_http_status(:ok)

        versions = tag.reload.tag_versions.order(:version_no)

        expect(versions.map(&:event_type)).to eq(['create', 'update'])

        expect(versions.first.name).to eq('spec_tag')
        expect(versions.first.category).to eq('general')
        expect(versions.first.aliases.split).to include('unko')

        expect(versions.second.name).to eq('put_versioned_tag')
        expect(versions.second.category).to eq('meta')
        expect(versions.second.aliases.split).to include('spec_tag')
        expect(versions.second.created_by_user_id).to eq(member_user.id)
      end

      it '同じ tag の旧 alias へ戻しても rename できる' do
        put "/tags/#{ tag.id }", params: {
          name: 'put_roundtrip_b',
          category: 'general',
          aliases: 'unko',
          parent_tags: '',
          deprecated: '0',
        }

        expect(response).to have_http_status(:ok)

        put "/tags/#{ tag.id }", params: {
          name: 'spec_tag',
          category: 'general',
          aliases: 'unko',
          parent_tags: '',
          deprecated: '0',
        }

        expect(response).to have_http_status(:ok)

        tag.reload

        expect(tag.name).to eq('spec_tag')
        expect(TagName.find_by!(name: 'put_roundtrip_b').canonical).to eq(tag.tag_name)
        expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('put_roundtrip_b', 'unko')
        expect(tag.tag_name.aliases.map(&:name)).not_to include('spec_tag')
        expect(alias_tn.reload.canonical).to eq(tag.tag_name)

        version = tag.tag_versions.order(:version_no).last

        expect(version.event_type).to eq('update')
        expect(version.name).to eq('spec_tag')
        expect(version.aliases.split).to contain_exactly('put_roundtrip_b', 'unko')
      end

      it 'parent tag の snapshot も作成する' do
        old_parent = Tag.create!(
          tag_name: TagName.create!(name: 'put_snapshot_old_parent'),
          category: :general
        )
        new_parent = Tag.create!(
          tag_name: TagName.create!(name: 'put_snapshot_new_parent'),
          category: :general
        )
        TagImplication.create!(tag:, parent_tag: old_parent)

        put "/tags/#{ tag.id }", params: {
          name: 'spec_tag',
          category: 'general',
          aliases: 'unko',
          parent_tags: new_parent.name,
          deprecated: '0',
        }

        expect(response).to have_http_status(:ok)

        expect(old_parent.reload.tag_versions.map(&:event_type)).to include('create')
        expect(new_parent.reload.tag_versions.map(&:event_type)).to include('create')
      end

      it 'normal tag を nico category には変更できない' do
        expect {
          put "/tags/#{ tag.id }", params: {
            name: 'spec_tag',
            category: 'nico',
            aliases: '',
            parent_tags: '',
            deprecated: '0',
          }
        }.not_to change(TagVersion, :count)

        expect(response).to have_http_status(:unprocessable_entity)

        expect(tag.reload.name).to eq('spec_tag')
        expect(tag.category).to eq('general')
      end

      it 'nico tag は更新できない' do
        nico_tag = Tag.create!(
          tag_name: TagName.create!(name: 'nico:put_update_all_ng'),
          category: :nico
        )

        expect {
          put "/tags/#{ nico_tag.id }", params: {
            name: 'nico:put_update_all_renamed',
            category: 'nico',
            aliases: '',
            parent_tags: '',
            deprecated: '0',
          }
        }.not_to change(NicoTagVersion, :count)

        expect(response).to have_http_status(:unprocessable_entity)

        expect(nico_tag.reload.name).to eq('nico:put_update_all_ng')
        expect(nico_tag.category).to eq('nico')
      end

      it 'system tag の name は変更できない' do
        system_tag = Tag.tagme
        old_name = system_tag.name
        old_category = system_tag.category

        expect {
          put "/tags/#{ system_tag.id }", params: {
            name: 'put_system_tag_renamed',
            category: old_category,
            aliases: '',
            parent_tags: '',
            deprecated: '0',
          }
        }.not_to change(TagVersion, :count)

        expect(response).to have_http_status(:unprocessable_entity)

        expect(system_tag.reload.name).to eq(old_name)
        expect(system_tag.category).to eq(old_category)
      end

      it 'wiki を持つ tag を更新すると wiki version も作成する' do
        wiki_page =
            Wiki::Commit.create_content!(
                tag_name: tag.tag_name,
                body: 'wiki body before',
                created_by_user: member_user,
                message: 'init')

        Wiki::Commit.content!(
          page: wiki_page,
          body: 'wiki body before',
          created_user: member_user,
          message: 'init'
        )

        expect {
          put "/tags/#{ tag.id }", params: {
            name: 'put_wiki_version_tag',
            category: 'meme',
            aliases: 'unko',
            parent_tags: '',
            deprecated: '0',
          }
        }
            .to change(TagVersion, :count).by(2)
            .and change(WikiVersion, :count).by(1)

        expect(response).to have_http_status(:ok)

        version = wiki_page.reload.wiki_versions.order(:version_no).last

        expect(version).to have_attributes(
          event_type: 'update',
          title: 'put_wiki_version_tag',
          body: 'wiki body before',
          created_by_user_id: member_user.id
        )
      end

      it 'wiki を持つ tag を旧 alias へ戻しても wiki を新 canonical へ移す' do
        wiki_page =
            Wiki::Commit.create_content!(
                tag_name: tag.tag_name,
                body: 'wiki body before',
                created_by_user: member_user,
                message: 'init')

        expect {
          put "/tags/#{ tag.id }", params: {
            name: 'put_wiki_roundtrip_b',
            category: 'general',
            aliases: 'unko',
            parent_tags: '',
            deprecated: '0',
          }

          put "/tags/#{ tag.id }", params: {
            name: 'spec_tag',
            category: 'general',
            aliases: 'unko',
            parent_tags: '',
            deprecated: '0',
          }
        }
            .to change(TagVersion, :count).by(3)
            .and change(WikiVersion, :count).by(2)

        expect(response).to have_http_status(:ok)

        tag.reload

        expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
        expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').wiki_page).to be_nil
        expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').canonical).to eq(tag.tag_name)

        versions = wiki_page.wiki_versions.order(:version_no).last(2)

        expect(versions.map(&:event_type)).to eq(['update', 'update'])
        expect(versions.map(&:title)).to eq(['put_wiki_roundtrip_b', 'spec_tag'])
      end

      it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
        old_owner = Tag.create!(
          tag_name: TagName.create!(name: 'put_alias_old_owner'),
          category: :general
        )
        stolen_alias = TagName.create!(
          name: 'put_stolen_alias',
          canonical: old_owner.tag_name
        )

        expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias')

        expect {
          put "/tags/#{ tag.id }", params: {
            name: 'spec_tag',
            category: 'general',
            aliases: 'unko put_stolen_alias',
            parent_tags: '',
            deprecated: '0',
          }
        }
            .to change { tag.reload.tag_versions.count }.by(2)
            .and change { old_owner.reload.tag_versions.count }.by(2)

        expect(response).to have_http_status(:ok)

        expect(stolen_alias.reload.canonical).to eq(tag.tag_name)
        expect(old_owner.reload.tag_name.aliases.map(&:name)).not_to include('put_stolen_alias')

        old_owner_versions = old_owner.tag_versions.order(:version_no)

        expect(old_owner_versions.first.event_type).to eq('create')
        expect(old_owner_versions.first.aliases.split).to include('put_stolen_alias')

        expect(old_owner_versions.second.event_type).to eq('update')
        expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias')
      end

      it '別 tag の alias 名を rename で奪へる' do
        old_owner = Tag.create!(
          tag_name: TagName.create!(name: 'put_alias_collision_owner'),
          category: :general
        )
        stolen_alias = TagName.create!(
          name: 'put_alias_collision_name',
          canonical: old_owner.tag_name
        )
        wiki_page =
            Wiki::Commit.create_content!(
                tag_name: tag.tag_name,
                body: 'put collision wiki',
                created_by_user: member_user,
                message: 'init')

        put "/tags/#{ tag.id }", params: {
          name: 'put_alias_collision_name',
          category: 'general',
          aliases: 'unko',
          parent_tags: '',
          deprecated: '0',
        }

        expect(response).to have_http_status(:ok)

        tag.reload
        old_owner.reload
        stolen_alias.reload

        expect(tag.name).to eq('put_alias_collision_name')
        expect(stolen_alias.canonical_id).to be_nil
        expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.tag_name)
        expect(old_owner.tag_name.aliases.map(&:name)).not_to include('put_alias_collision_name')

        old_owner_versions = old_owner.tag_versions.order(:version_no)

        expect(old_owner_versions.last.event_type).to eq('update')
        expect(old_owner_versions.last.aliases.split).not_to include('put_alias_collision_name')
        expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
      end

      it 'parent_tags に指定すると循環する tag は 422 にする' do
        child = Tag.create!(
          tag_name: TagName.create!(name: 'put_cycle_child'),
          category: :general
        )

        TagImplication.create!(tag: child, parent_tag: tag)

        put "/tags/#{ tag.id }", params: {
          name: 'spec_tag',
          category: 'general',
          aliases: 'unko',
          parent_tags: child.name,
          deprecated: '0',
        }

        expect(response).to have_http_status(:unprocessable_entity)

        expect(TagImplication.where(tag:, parent_tag: child)).not_to exist
      end

      it 'tag の name を変更すると対応する wiki version を作成する' do
        wiki_page =
            Wiki::Commit.create_content!(
                tag_name: tag.tag_name,
                body: 'wiki body before',
                created_by_user: member_user,
                message: 'init')

        expect {
          put "/tags/#{ tag.id }", params: {
            name: 'put_wiki_renamed_tag',
            category: 'general',
            aliases: 'unko',
            parent_tags: '',
            deprecated: '0',
          }
        }
            .to change(TagVersion, :count).by(2)
            .and change(WikiVersion, :count).by(1)

        expect(response).to have_http_status(:ok)

        version = wiki_page.reload.wiki_versions.order(:version_no).last

        expect(version).to have_attributes(
          event_type: 'update',
          title: 'put_wiki_renamed_tag',
          body: 'wiki body before',
          created_by_user_id: member_user.id
        )
      end
    end
  end
end
