class PostUrlSanitisationRule < ApplicationRecord
  include Discard::Model

  class InvalidUrlError < StandardError
    attr_reader :invalid_rows

    def initialize(invalid_rows)
      @invalid_rows = invalid_rows
      ids = invalid_rows.map { _1.fetch(:post_id) }.join(', ')
      super("post URL sanitisation produced invalid URLs for posts #{ ids }")
    end
  end

  class UrlConflictError < StandardError
    attr_reader :conflicts

    def initialize(conflicts)
      @conflicts = conflicts
      urls = conflicts.map { _1.fetch(:url) }.uniq.join(', ')
      super("post URL sanitisation conflicts detected for #{ urls }")
    end
  end

  self.primary_key = :priority

  default_scope -> { kept }

  validates :source_pattern, presence: true, uniqueness: true

  validate :source_pattern_must_be_regexp

  class << self
    def sanitise(url) = sanitise_with_rules(url, rules)

    def apply!
      rewrites = nil

      Post.transaction do
        compiled_rules = rules

        rewrites = Post.order(:id)
                       .lock('FOR UPDATE')
                       .pluck(:id, :url)
                       .map do |post_id, original_url|
          { post_id:,
            original_url:,
            sanitised_url: sanitise_with_rules(original_url, compiled_rules) }
        end

        invalid_rows = rewrites.filter { invalid_sanitised_url?(_1.fetch(:sanitised_url)) }
                               .map { { post_id: _1.fetch(:post_id),
                                        original_url: _1.fetch(:original_url),
                                        sanitised_url: _1.fetch(:sanitised_url) } }
        raise InvalidUrlError.new(invalid_rows) if invalid_rows.present?

        conflicts = build_conflicts(rewrites)
        raise UrlConflictError.new(conflicts) if conflicts.present?

        changed = rewrites.filter { _1.fetch(:original_url) != _1.fetch(:sanitised_url) }
        return if changed.empty?

        token = SecureRandom.hex(6)

        changed.each do |row|
          Post.where(id: row.fetch(:post_id))
              .update_all(url: temporary_url_for(row.fetch(:post_id), token))
        end

        changed.each do |row|
          Post.where(id: row.fetch(:post_id))
              .update_all(url: row.fetch(:sanitised_url))
        end
      end
    rescue ActiveRecord::RecordNotUnique => error
      conflicts = build_persisted_conflicts(rewrites)
      raise error if conflicts.empty?

      raise UrlConflictError.new(conflicts), cause: error
    end

    private

    def rules = kept.order(:priority).map { |r| [Regexp.new(r.source_pattern), r.replacement] }

    def sanitise_with_rules(url, compiled_rules)
      compiled_rules.reduce(url.dup) do |value, (pattern, replacement)|
        value.sub(pattern, replacement)
      end
    end

    def temporary_url_for(post_id, token) =
        "https://post-url-sanitising.invalid/#{ token }/#{ post_id }"

    def invalid_sanitised_url?(url)
      return true if url.blank?
      return true if url.length > 768

      parsed = URI.parse(url)
      return true if !(parsed in URI::HTTP)
      return true if parsed.host.blank?

      false
    rescue URI::InvalidURIError
      true
    end

    def build_conflicts(rewrites)
      rewrites
          .group_by { _1.fetch(:sanitised_url).downcase }
          .values
          .filter { _1.size > 1 }
          .flatten
          .map { { url: _1.fetch(:sanitised_url),
                   post_id: _1.fetch(:post_id),
                   original_url: _1.fetch(:original_url) } }
    end

    def build_persisted_conflicts(rewrites)
      return [] if rewrites.blank?

      target_rows = rewrites.filter { _1.fetch(:original_url) != _1.fetch(:sanitised_url) }
      target_keys = target_rows.map { _1.fetch(:sanitised_url).downcase }.uniq
      return [] if target_keys.empty?

      target_pairs = target_rows.to_h do |row|
        [row.fetch(:post_id), row.fetch(:sanitised_url).downcase]
      end

      persisted_rows = Post.order(:id)
                           .where('LOWER(url) IN (?)', target_keys)
                           .pluck(:id, :url)
                           .reject { |post_id, original_url| target_pairs[post_id] == original_url.downcase }
                           .map { |post_id, original_url|
        { url: original_url,
          post_id:,
          original_url:,
          conflict_key: original_url.downcase }
      }

      target_conflicts = target_rows.map { { url: _1.fetch(:sanitised_url),
                                             post_id: _1.fetch(:post_id),
                                             original_url: _1.fetch(:original_url),
                                             conflict_key: _1.fetch(:sanitised_url).downcase } }

      (persisted_rows + target_conflicts)
          .group_by { _1.fetch(:conflict_key) }
          .values
          .filter { _1.size > 1 }
          .flatten
          .map { _1.except(:conflict_key) }
    end
  end

  private

  def source_pattern_must_be_regexp
    return if source_pattern.blank?

    Regexp.new(source_pattern)
  rescue RegexpError
    errors.add :source_pattern, '変な正規表現だね〜（笑）'
  end
end
