require 'set'


class Tag < ApplicationRecord
  include MyDiscard

  class NicoTagNormalisationError < ArgumentError
    ;
  end

  class DeprecatedTagNormalisationError < ArgumentError
    attr_reader :tag_names

    def initialize tag_names
      @tag_names = Array(tag_names)
      super('deprecated tags are not allowed')
    end
  end

  class SectionLiteralParseError < ArgumentError
    attr_reader :tag_name, :literal

    def initialize tag_name, literal
      @tag_name = tag_name
      @literal = literal
      super("invalid section literal for tag #{ tag_name }: #{ literal }")
    end
  end

  has_many :post_tags, inverse_of: :tag
  has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
  has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
  has_many :posts, through: :active_post_tags

  has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
  has_many :linked_tags, through: :nico_tag_relations, source: :tag

  has_many :reversed_nico_tag_relations,
           class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy
  has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag

  has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
  has_many :children, through: :tag_implications, source: :tag

  has_many :reversed_tag_implications,
           class_name: 'TagImplication', foreign_key: :tag_id, dependent: :destroy
  has_many :parents, through: :reversed_tag_implications, source: :parent_tag

  has_many :tag_similarities, dependent: :delete_all
  has_many :tag_similarities_as_target,
           class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all

  has_many :deerjikists, dependent: :delete_all
  has_many :materials

  has_many :tag_versions
  has_many :nico_tag_versions

  belongs_to :tag_name
  delegate :wiki_page, to: :tag_name

  attribute :version_no, :integer, default: 1

  delegate :name, to: :tag_name, allow_nil: true
  validates :tag_name, presence: true

  enum :category, deerjikist:  'deerjikist',
                  meme:        'meme',
                  character:   'character',
                  general:     'general',
                  material:    'material',
                  nico:        'nico',
                  meta:        'meta'

  validates :category, presence: true, inclusion: { in: Tag.categories.keys }

  validate :nico_tag_name_must_start_with_nico
  validate :tag_name_must_be_canonical
  validate :category_must_be_deerjikist_with_deerjikists
  validate :nico_tags_cannot_be_deprecated

  scope :nico_tags, -> { nico }

  CATEGORY_PREFIXES = {
      'general:'    => :general,
      'gen:'        => :general,
      'deerjikist:' => :deerjikist,
      'djk:'        => :deerjikist,
      'meme:'       => :meme,
      'character:'  => :character,
      'chr:'        => :character,
      'material:'   => :material,
      'mtr:'        => :material,
      'meta:'       => :meta }.freeze

  def name= val
    (self.tag_name ||= build_tag_name).name = val
  end

  def deprecated? = deprecated_at?

  def has_wiki = wiki_page.present?

  def material_id = materials.first&.id

  def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?

  def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
  def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
  def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
  def self.video = find_or_create_by_tag_name!('動画', category: :meta)
  def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
  def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)

  def self.normalise_tags! tag_names, with_tagme: true,
                                      with_no_deerjikist: true,
                                      deny_nico: true,
                                      deny_deprecated: false,
                                      with_sections: false
    if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
      raise NicoTagNormalisationError
    end

    sections = { }
    tags = tag_names.map do |name|
      raw_name = name
      pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]

      name = name.sub(/\A#{ pf }/i, '')

      sections_by_tag = []
      while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
        name = "#{ match[1] }#{ match[4] }"
        next if match[2].empty? && match[3].empty?

        sections_by_tag << normalise_section_range!(
            begin_raw: match[2],
            end_raw: match[3],
            tag_name: name)
      end

      if name.include?('[') || name.include?(']')
        raise SectionLiteralParseError.new(raw_name, raw_name)
      end

      name = TagName.canonicalise(name).first

      find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
        if deny_deprecated && tag.deprecated?
          raise DeprecatedTagNormalisationError, [tag.name]
        end

        tag.update!(category: cat) if cat && tag.category != cat
        next if sections_by_tag.empty?

        sections[tag.id] ||= []
        sections[tag.id].concat(sections_by_tag)
        sections[tag.id] = merge_section_ranges(sections[tag.id])
        sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
      end
    end

    tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
    tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
    tags.uniq!(&:id)

    if with_sections
      { tags:, sections: }
    else
      tags
    end
  end

  def self.expand_parent_tags tags
    return [] if tags.blank?

    seen = Set.new
    result = []
    stack = tags.compact.dup

    until stack.empty?
      tag = stack.pop
      next unless tag

      tag.parents.each do |parent|
        next if seen.include?(parent.id)

        seen << parent.id
        result << parent
        stack << parent
      end
    end

    (result + tags).uniq { |t| t.id }
  end

  def self.normalise_section_range! begin_raw:, end_raw:, tag_name:
    begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:)
    end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:)

    if end_ms
      begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
      end_ms = begin_ms + 1 if begin_ms == end_ms
    end

    [begin_ms, end_ms]
  end

  def self.merge_section_ranges ranges
    sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
    merged = []

    sorted_ranges.each do |begin_ms, end_ms|
      if merged.empty?
        merged << [begin_ms, end_ms]
        next
      end

      last_begin_ms, last_end_ms = merged[-1]
      if last_end_ms.nil? || begin_ms <= last_end_ms
        merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)]
      else
        merged << [begin_ms, end_ms]
      end
    end

    merged
  end

  def self.merge_section_end left_end_ms, right_end_ms
    return nil if left_end_ms.nil? || right_end_ms.nil?

    [left_end_ms, right_end_ms].max
  end

  def self.find_or_create_by_tag_name! name, category:
    tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
    tn = tn.canonical if tn.canonical_id?

    Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t|
      t.category = category
    end
  rescue ActiveRecord::RecordNotUnique
    retry
  end

  def self.merge_tags! target_tag, source_tags, created_by_user: nil
    target_tag => Tag

    affected_post_ids = Set.new

    Tag.transaction do
      TagVersioning.ensure_snapshot!(target_tag, created_by_user:)

      Array(source_tags).compact.uniq.each do |source_tag|
        source_tag => Tag

        next if source_tag == target_tag

        TagVersioning.ensure_snapshot!(source_tag, created_by_user:)

        source_tag.post_tags.kept.find_each do |source_pt|
          post_id = source_pt.post_id
          affected_post_ids << post_id
          source_pt.discard_by!(created_by_user)
          unless PostTag.kept.exists?(post_id:, tag: target_tag)
            PostTag.create!(post_id:, tag: target_tag)
          end
        end

        source_tag_name = source_tag.tag_name

        if source_tag_name.wiki_page.present?
          raise ActiveRecord::RecordInvalid.new(source_tag_name)
        end

        TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
        source_tag.discard!

        if source_tag.nico?
          source_tag_name.discard!
        else
          source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
                                         updated_at: Time.current)
        end

        TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
      end

      Post.where(id: affected_post_ids.to_a).find_each do |post|
        PostVersionRecorder.ensure_snapshot!(post, created_by_user:)
        PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
      end

      # 投稿件数を再集計
      target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count)
    end

    target_tag.reload
  end

  def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)

  def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)

  def snapshot_linked_tag_names
    linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
  end

  private

  def nico_tag_name_must_start_with_nico
    n = name.to_s
    if ((nico? && !(n.downcase.start_with?('nico:'))) || 
        (!(nico?) && n.downcase.start_with?('nico:')))
      errors.add :name, 'ニコニコ・タグの命名規則に反してゐます．'
    end
  end

  def tag_name_must_be_canonical
    if tag_name&.canonical_id?
      errors.add :tag_name, 'tag_names へは実体を示す必要があります．'
    end
  end

  def category_must_be_deerjikist_with_deerjikists
    if !(deerjikist?) && deerjikists.exists?
      errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります．'
    end
  end

  def self.time_to_ms! str, tag_name:
    match =
        case str
        when /\A(?<seconds>\d+)(?:\.(?<ms>\d{1,3}))?\z/
          { hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds],
            ms: Regexp.last_match[:ms] }
        when /\A(?<minutes>\d+):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
          { hours: nil, minutes: Regexp.last_match[:minutes],
            seconds: Regexp.last_match[:seconds],
            ms: Regexp.last_match[:ms] }
        when /\A(?<hours>\d+):(?<minutes>[0-5]?\d):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
          { hours: Regexp.last_match[:hours],
            minutes: Regexp.last_match[:minutes],
            seconds: Regexp.last_match[:seconds],
            ms: Regexp.last_match[:ms] }
        end

    raise SectionLiteralParseError.new(tag_name, str) unless match

    total_s = match[:seconds].to_i
    total_s += match[:minutes].to_i * 60 if match[:minutes]
    total_s += match[:hours].to_i * 3_600 if match[:hours]

    total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
  end

  def nico_tags_cannot_be_deprecated
    if nico? && deprecated_at.present?
      errors.add :deprecated_at, 'ニコタグは廃止できません．'
    end
  end
end
