require 'rails_helper'

RSpec.describe Tag, type: :model do
  describe '.normalise_tags!' do
    it 'rejects deprecated tags when deny_deprecated is enabled' do
      tag_name = TagName.create!(name: 'normalise deprecated tag')
      deprecated_tag = Tag.create!(
        tag_name:,
        category: :general,
        deprecated_at: 1.day.from_now
      )

      expect {
        described_class.normalise_tags!(
          [deprecated_tag.name],
          deny_deprecated: true
        )
      }.to raise_error(Tag::DeprecatedTagNormalisationError) { |error|
        expect(error.tag_names).to eq([deprecated_tag.name])
      }
    end

    it 'rejects invalid section literals instead of treating them as zero' do
      expect {
        described_class.normalise_tags!(
          ['normalise_invalid_section[1:aa-2:00]'],
          with_sections: true
        )
      }.to raise_error(Tag::SectionLiteralParseError)
    end

    it 'parses open-ended section literals' do
      result = described_class.normalise_tags!(
        ['伊地知ニジカ[1:00-]'],
        with_sections: true
      )

      tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
      expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
    end

    it 'parses omitted begin as zero' do
      result = described_class.normalise_tags!(
        ['伊地知ニジカ[-1:00]'],
        with_sections: true
      )

      tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
      expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]])
    end

    it 'treats fully open section literals as plain tags' do
      result = described_class.normalise_tags!(
        ['伊地知ニジカ[-]'],
        with_sections: true
      )

      tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
      expect(result.fetch(:sections)[tag.id]).to be_nil
    end

    it 'treats [0:00-] as a plain tag' do
      result = described_class.normalise_tags!(
        ['伊地知ニジカ[0:00-]'],
        with_sections: true
      )

      tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
      expect(result.fetch(:sections)[tag.id]).to be_nil
    end

    it 'expands zero-width sections to one millisecond' do
      result = described_class.normalise_tags!(
        ['伊地知ニジカ[1:00-1:00]'],
        with_sections: true
      )

      tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
      expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]])
    end

    it 'swaps reversed section boundaries' do
      result = described_class.normalise_tags!(
        ['伊地知ニジカ[2:00-1:00]'],
        with_sections: true
      )

      tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
      expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]])
    end

    it 'merges open-ended sections over later bounded sections' do
      result = described_class.normalise_tags!(
        ['伊地知ニジカ[1:00-][2:00-3:00]'],
        with_sections: true
      )

      tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
      expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
    end

    it 'merges adjacent bounded and open-ended sections' do
      result = described_class.normalise_tags!(
        ['伊地知ニジカ[1:00-3:00][3:00-]'],
        with_sections: true
      )

      tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
      expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
    end
  end

  describe '.expand_parent_tags' do
    it 'expands through multiple deprecated parents to an active ancestor' do
      child = create(:tag, name: 'expand_child')
      deprecated_parent = create(
        :tag,
        name: 'expand_deprecated_parent',
        deprecated_at: Time.current
      )
      deprecated_grandparent = create(
        :tag,
        name: 'expand_deprecated_grandparent',
        deprecated_at: Time.current
      )
      active_ancestor = create(:tag, name: 'expand_active_ancestor')
      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_ancestor)

      expanded = described_class.expand_parent_tags([child])

      expect(expanded).to include(
        child,
        deprecated_parent,
        deprecated_grandparent,
        active_ancestor
      )
      expect(expanded.reject(&:deprecated?)).to contain_exactly(child, active_ancestor)
    end

    it 'terminates when implications contain a cycle' do
      first = create(:tag, name: 'expand_cycle_first')
      second = create(:tag, name: 'expand_cycle_second')
      TagImplication.create!(tag: first, parent_tag: second)
      now = Time.current
      TagImplication.insert_all!(
        [
          {
            tag_id: second.id,
            parent_tag_id: first.id,
            created_at: now,
            updated_at: now
          }
        ]
      )

      expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
    end
  end

  describe 'deprecated validation' do
    it 'rejects deprecated nico tags' do
      tag = build(
        :tag,
        name: 'nico:deprecated_validation',
        category: :nico,
        deprecated_at: Time.current
      )

      expect(tag).not_to be_valid
      expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません．')
    end
  end

  describe '.merge_tags!' do
    let!(:target_tag) { create(:tag, category: :general) }
    let!(:source_tag) { create(:tag, category: :general) }
    let!(:source_tag_name) { source_tag.tag_name }

    let!(:post_record) do
      Post.create!(url: 'https://example.com/posts/1', title: 'test post')
    end

    context 'when merging a simple source tag' do
      let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }

      it 'discards the source post_tag, creates an active target post_tag, discards the source tag, and aliases the source tag_name' do
        described_class.merge_tags!(target_tag, [source_tag])

        source_pt = PostTag.with_discarded.find(source_post_tag.id)
        active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)

        expect(source_pt.discarded_at).to be_present
        expect(source_pt.tag_id).to eq(source_tag.id)
        expect(active_target).to be_present

        expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
        expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
        expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
        expect(target_tag.reload.post_count).to eq(1)
      end
    end

    context 'when the target already has the same post_tag' do
      let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
      let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }

      it 'discards the source post_tag, keeps one active target post_tag, discards the source tag, and aliases the source tag_name' do
        described_class.merge_tags!(target_tag, [source_tag])

        source_pt = PostTag.with_discarded.find(source_post_tag.id)
        active = PostTag.kept.where(post_id: post_record.id, tag_id: target_tag.id)

        expect(source_pt.discarded_at).to be_present
        expect(source_pt.tag_id).to eq(source_tag.id)
        expect(active.count).to eq(1)
        expect(active.first.id).to eq(target_post_tag.id)

        expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
        expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
        expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
        expect(target_tag.reload.post_count).to eq(1)
      end
    end

    context 'when source_tags includes the target itself' do
      let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }

      it 'ignores the target in source_tags while still merging the source tag' do
        described_class.merge_tags!(target_tag, [source_tag, target_tag])

        source_pt = PostTag.with_discarded.find(source_post_tag.id)
        active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)

        expect(Tag.find(target_tag.id)).to be_present
        expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
        expect(source_pt.discarded_at).to be_present
        expect(source_pt.tag_id).to eq(source_tag.id)
        expect(active_target).to be_present
        expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
        expect(target_tag.reload.post_count).to eq(1)
      end
    end

    context 'when the source tag_name is invalid under sanitisation rules' do
      let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
      let!(:sanitisation_rule) do
        TagNameSanitisationRule.create!(
          priority: 99_999,
          source_pattern: 'INVALIDTOKEN',
          replacement: ''
        )
      end

      before do
        source_tag_name.update_columns(
          name: "#{ source_tag_name.name }INVALIDTOKEN",
          updated_at: Time.current
        )
      end

      it 'still merges, but discards the source tag_name instead of aliasing it' do
        described_class.merge_tags!(target_tag, [source_tag])

        source_pt = PostTag.with_discarded.find(source_post_tag.id)
        active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
        discarded_source_tag_name = TagName.with_discarded.find(source_tag_name.id)

        expect(source_pt.discarded_at).to be_present
        expect(source_pt.tag_id).to eq(source_tag.id)
        expect(active_target).to be_present

        expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
        expect(target_tag.reload.post_count).to eq(1)
      end
    end

    context 'when the source tag_name has a wiki_page' do
      let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
      let!(:wiki_page) do
        admin = create_admin_user!

        Wiki::Commit.create_content!(
            tag_name: source_tag_name,
            body: 'source wiki body',
            created_by_user: admin,
            message: 'init')
      end

      it 'rolls back the transaction' do
        expect {
          described_class.merge_tags!(target_tag, [source_tag])
        }.to raise_error(ActiveRecord::RecordInvalid)

        expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded
        expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
        expect(PostTag.kept.find(source_post_tag.id).tag_id).to eq(source_tag.id)
        expect(PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)).to be_nil
        expect(source_tag_name.reload.canonical_id).to be_nil
        expect(target_tag.reload.post_count).to eq(0)
      end
    end

    context 'when merging a nico source tag' do
      let!(:target_tag) { create(:tag, category: :nico, name: 'nico:foo') }
      let!(:source_tag) { create(:tag, category: :nico, name: 'nico:bar') }
      let!(:source_tag_name_id) { source_tag.tag_name_id }

      it 'discards the source tag_name instead of aliasing it' do
        described_class.merge_tags!(target_tag, [source_tag])

        discarded_source_tag = Tag.with_discarded.find(source_tag.id)
        discarded_source_tag_name = TagName.with_discarded.find(source_tag_name_id)

        expect(discarded_source_tag).to be_discarded
        expect(discarded_source_tag_name).to be_discarded
        expect(discarded_source_tag_name.canonical_id).to be_nil
        expect(target_tag.reload.post_count).to eq(0)
      end
    end

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

    def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
      PostVersion.create!(
        post: post,
        version_no: version_no,
        event_type: 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: Time.current,
        created_by_user: created_by_user
      )
    end

    context 'when post versions are enabled' do
      let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
      let!(:unaffected_post) do
        Post.create!(url: 'https://example.com/posts/2', title: 'unaffected post')
      end

      before do
        create_post_version_for!(post_record)
        create_post_version_for!(unaffected_post)
      end

      it 'creates an update post_version only for affected posts' do
        expect {
          described_class.merge_tags!(target_tag, [source_tag])
        }.to change(PostVersion, :count).by(1)

        affected_versions = post_record.reload.post_versions.order(:version_no)
        expect(affected_versions.pluck(:version_no)).to eq([1, 2])

        latest = affected_versions.last
        expect(latest.event_type).to eq('update')
        expect(latest.created_by_user).to be_nil
        expect(latest.tags).to eq(snapshot_tags(post_record.reload))

        expect(unaffected_post.reload.post_versions.count).to eq(1)
      end
    end

    context 'when the source tag has no active post_tags' do
      let!(:another_post) do
        Post.create!(url: 'https://example.com/posts/3', title: 'another post')
      end

      before do
        create_post_version_for!(another_post)
      end

      it 'does not create any post_version' do
        expect {
          described_class.merge_tags!(target_tag, [source_tag])
        }.not_to change(PostVersion, :count)
      end
    end
  end
end
