class Post < ApplicationRecord
  require 'date'
  require 'mini_magick'
  require 'nokogiri'
  require 'stringio'
  require 'timeout'

  class RemoteThumbnailFetchFailed < StandardError; end

  ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です．'.freeze
  ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE =
      'オリジナルの作成日時は分単位で入力してください．'.freeze
  ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです．'.freeze
  ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE =
      'オリジナルの作成日時の範囲は1分以上必要です．'.freeze
  REMOTE_SVG_CONTENT_TYPE = 'image/svg+xml'.freeze
  MAX_SVG_DIMENSION = 4_096
  MAX_SVG_PIXELS = 16_777_216
  THUMBNAIL_PROCESS_TIMEOUT = 5.seconds
  def self.resized_thumbnail_attachment(upload, content_type: nil)
    upload.rewind
    bytes = upload.read
    blob = Timeout.timeout(THUMBNAIL_PROCESS_TIMEOUT) do
      image = image_for_thumbnail_upload(bytes, content_type:)
      image.auto_orient
      image.resize '180x180'
      image.format 'jpg'
      image.to_blob
    end

    { io: StringIO.new(blob),
      filename: 'resized_thumbnail.jpg',
      content_type: 'image/jpeg' }
  rescue Timeout::Error
    raise MiniMagick::Error, 'サムネイル画像の変換に失敗しました．'
  ensure
    upload.rewind
  end

  def self.remote_thumbnail_attachment(raw_url)
    response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
    resized_thumbnail_attachment(
        StringIO.new(response.body),
        content_type: response.content_type)
  rescue Preview::UrlSafety::UnsafeUrl,
         Preview::ThumbnailFetcher::GenerationFailed,
         Preview::HttpFetcher::FetchFailed,
         Preview::HttpFetcher::FetchTimeout,
         Preview::HttpFetcher::ResponseTooLarge,
         Timeout::Error,
         MiniMagick::Error => e
    raise RemoteThumbnailFetchFailed, e.message
  end

  belongs_to :uploaded_user, class_name: 'User', optional: true

  has_many :post_tags, dependent: :destroy, inverse_of: :post
  has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
  has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
  has_many :tags, through: :active_post_tags
  has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
           through: :active_post_tags, source: :tag

  has_many :user_post_views, dependent: :delete_all
  has_many :post_similarities, dependent: :delete_all
  has_many :post_versions
  has_many :gekanator_guessed_games,
           class_name: 'GekanatorGame',
           foreign_key: :guessed_post_id,
           dependent: :delete_all,
           inverse_of: :guessed_post
  has_many :gekanator_correct_games,
           class_name: 'GekanatorGame',
           foreign_key: :correct_post_id,
           dependent: :delete_all,
           inverse_of: :correct_post
  has_many :gekanator_question_examples, dependent: :delete_all

  has_many :parent_post_implications,
           class_name: 'PostImplication',
           foreign_key: :post_id,
           dependent: :destroy,
           inverse_of: :post
  has_many :parents, through: :parent_post_implications, source: :parent_post

  has_many :child_post_implications,
           class_name: 'PostImplication',
           foreign_key: :parent_post_id,
           dependent: :destroy,
           inverse_of: :parent_post
  has_many :children, through: :child_post_implications, source: :post

  has_one_attached :thumbnail

  attribute :version_no, :integer, default: 1

  before_validation :normalise_url, if: :will_save_change_to_url?

  validates :url, presence: true, uniqueness: true, length: { maximum: 768 }
  validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true

  validate :validate_original_created_range
  validate :url_must_be_http_url

  def parent_posts = parents

  def child_posts = children

  def sibling_posts
    parent_post_ids = parent_posts.order(:id).pluck(:id)

    parent_post_ids.to_h { [_1, PostImplication.where(parent_post: _1).map(&:post)] }
  end

  def as_json options = { }
    super(options).merge(thumbnail: thumbnail.attached? ?
                                    Rails.application.routes.url_helpers.rails_blob_url(
                                        thumbnail, only_path: false) :
                                    nil)
  rescue
    super(options).merge(thumbnail: nil)
  end

  def snapshot_tag_names
    post_tags
        .kept
        .joins(tag: :tag_name)
        .includes(:sections, tag: :tag_name)
        .order('tag_names.name')
        .map do |post_tag|
      name = post_tag.tag.tag_name.name
      sections = post_tag.sections.sort_by(&:begin_ms)

      next name if sections.empty?

      "#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
    end
  end

  def self.tag_snapshot_literal tag
    sections = tag.fetch('sections', []).map do |sec|
      begin_ms = sec.fetch('begin_ms')
      end_ms = sec['end_ms']

      "[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
    end

    "#{ tag.fetch('name') }#{ sections.join }"
  end

  def snapshot_tags_json
    post_tags
        .kept
        .joins(tag: :tag_name)
        .includes(:sections, tag: :tag_name)
        .order('tags.id')
        .map do |pt|
      { 'id' => pt.tag.id,
        'version_no' => pt.tag.version_no,
        'name' => pt.tag.name,
        'category' => pt.tag.category,
        'sections' => pt.sections.sort_by(&:begin_ms).map {
          { 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms }
        } }
    end
  end

  def self.section_literal section
    end_ms =
        section.end_ms ? Post.ms_to_time(section.end_ms) : ''

    "[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
  end

  def self.ms_to_time ms
    total_s = ms / 1_000
    s = total_s % 60
    min = (total_s / 60) % 60
    h = total_s / 3_600
    remainder_ms = ms % 1_000

    base =
        if h.positive?
          '%d:%02d:%02d' % [h, min, s]
        else
          '%d:%02d' % [min, s]
        end

    if remainder_ms.positive?
      "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }"
    else
      base
    end
  end

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

  def related limit: nil
    ids = post_similarities.order(cos: :desc)
    ids = ids.limit(limit) if limit
    ids = ids.pluck(:target_post_id)
    return Post.none if ids.empty?

    Post.where(id: ids)
        .with_attached_thumbnail
        .order(Arel.sql("FIELD(posts.id, #{ ids.join(',') })"))
  end

  def resized_thumbnail!
    return unless thumbnail.attached?

    thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
  end

  def attach_thumbnail_from_url! raw_url
    thumbnail.attach(self.class.remote_thumbnail_attachment(raw_url))
  end

  private

  def validate_original_created_range
    return if skip_original_created_validation?

    f = parse_original_created_value(:original_created_from)
    b = parse_original_created_value(:original_created_before)
    return if f.nil? || b.nil?

    if b <= f
      errors.add :original_created_at, ORIGINAL_CREATED_ORDER_MESSAGE
      return
    end

    if b - f < 1.minute
      errors.add :original_created_at, ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE
    end
  end

  def url_must_be_http_url
    begin
      u = URI.parse(url)
    rescue URI::InvalidURIError
      errors.add(:url, 'URL が不正です．')
      return
    end

    if !(u in URI::HTTP) || u.host.blank?
      errors.add(:url, 'URL が不正です．')
      return
    end
  end

  def normalise_url
    return if url.blank?

    self.url = PostUrlNormaliser.normalise(url) || url.strip
  end

  def self.image_for_thumbnail_upload(bytes, content_type: nil)
    if svg_content_type?(content_type) || svg_document_bytes?(bytes)
      return decode_svg_thumbnail(bytes)
    end

    raise MiniMagick::Error, 'サムネイル画像の形式が不正です．' unless raster_thumbnail_bytes?(bytes)

    decode_raster_thumbnail(bytes)
  end

  def self.raster_thumbnail_bytes?(bytes)
    raster_thumbnail_format(bytes).present?
  end

  def self.remote_thumbnail_image_bytes?(bytes, content_type: nil)
    return true if svg_content_type?(content_type) || svg_document_bytes?(bytes)

    raster_thumbnail_bytes?(bytes)
  end

  def self.svg_content_type?(content_type)
    content_type.to_s.split(';', 2).first.to_s.downcase.strip == REMOTE_SVG_CONTENT_TYPE
  end

  def self.svg_document_bytes?(bytes)
    document = Nokogiri::XML(
        bytes,
        nil,
        nil,
        Nokogiri::XML::ParseOptions::STRICT |
        Nokogiri::XML::ParseOptions::NONET)
    document.root&.name == 'svg'
  rescue Nokogiri::XML::SyntaxError
    false
  end

  def self.decode_raster_thumbnail(bytes)
    MiniMagick::Image.read(bytes)
  end

  def self.decode_svg_thumbnail(bytes)
    MiniMagick::Image.read(sanitised_svg_bytes(bytes))
  end

  def self.raster_thumbnail_format(bytes)
    binary = bytes.to_s.b
    return 'jpeg' if binary.start_with?("\xFF\xD8\xFF".b)
    return 'png' if binary.start_with?("\x89PNG\r\n\x1A\n".b)
    return 'gif' if binary.start_with?('GIF87a'.b) || binary.start_with?('GIF89a'.b)
    return 'webp' if binary.bytesize >= 12 &&
                     binary.start_with?('RIFF'.b) &&
                     binary.byteslice(8, 4) == 'WEBP'

    nil
  end

  def self.sanitised_svg_bytes(bytes)
    parse_options =
        Nokogiri::XML::ParseOptions::STRICT |
        Nokogiri::XML::ParseOptions::NONET
    document = Nokogiri::XML(
        bytes,
        nil,
        nil,
        parse_options)
    root = document.root
    raise MiniMagick::Error, 'SVG が不正です．' if root == nil || root.name != 'svg'
    raise MiniMagick::Error, 'SVG が不正です．' if document.internal_subset != nil
    raise MiniMagick::Error, 'SVG が不正です．' if svg_uses_disallowed_features?(document)

    width, height = svg_dimensions(root)
    raise MiniMagick::Error, 'SVG が大きすぎます．' if width == nil || height == nil
    if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION || width * height > MAX_SVG_PIXELS
      raise MiniMagick::Error, 'SVG が大きすぎます．'
    end

    document.to_xml
  rescue Nokogiri::XML::SyntaxError
    raise MiniMagick::Error, 'SVG が不正です．'
  end

  def self.svg_uses_disallowed_features?(document)
    document.traverse.any? do |node|
      next false unless node.element?

      name = node.name.to_s.downcase
      next true if name == 'script' || name == 'foreignobject'
      next style_contains_disallowed_urls?(node.text.to_s) if name == 'style'

      node.attribute_nodes.any? do |attribute|
        attribute_name = attribute.name.to_s.downcase
        attribute_value = attribute.value.to_s
        attribute_name.start_with?('on') ||
            external_svg_reference?(attribute_name, attribute_value)
      end
    end
  end

  def self.external_svg_reference?(attribute_name, attribute_value)
    if ['href', 'xlink:href', 'src'].include?(attribute_name)
      return external_svg_url?(attribute_value)
    end
    return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style'
    return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)

    false
  end

  def self.external_svg_url?(value)
    stripped = value.to_s.strip
    return false if stripped.blank? || stripped.start_with?('#')

    true
  end

  def self.style_contains_disallowed_urls?(value)
    text = value.to_s
    text.match?(/@import/i) || svg_url_function_disallowed?(text)
  end

  def self.svg_url_function_disallowed?(value)
    value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
      reference =
          entry.to_s.strip
               .delete_prefix("'")
               .delete_prefix('"')
               .delete_suffix("'")
               .delete_suffix('"')
      reference.present? && !(reference.start_with?('#'))
    end
  end

  def self.svg_dimensions(root)
    width = svg_length_to_pixels(root['width'])
    height = svg_length_to_pixels(root['height'])
    return [width, height] if width && height

    view_box = root['viewBox'].to_s.strip.split(/\s+/).map { Float(_1) rescue nil }
    return [nil, nil] if view_box.length != 4 || view_box.any?(&:nil?)
    return [nil, nil] unless view_box[2].finite? && view_box[2].positive?
    return [nil, nil] unless view_box[3].finite? && view_box[3].positive?

    [view_box[2], view_box[3]]
  end

  def self.svg_length_to_pixels(value)
    return nil if value.blank?

    matched = /\A([0-9]+(?:\.[0-9]+)?)(px)?\z/i.match(value.to_s.strip)
    return nil if matched == nil

    pixels = Float(matched[1])
    return nil unless pixels.finite? && pixels.positive?

    pixels
  rescue ArgumentError
    nil
  end

  private_class_method :image_for_thumbnail_upload,
                       :svg_content_type?,
                       :decode_raster_thumbnail,
                       :decode_svg_thumbnail,
                       :raster_thumbnail_format,
                       :sanitised_svg_bytes,
                       :svg_uses_disallowed_features?,
                       :external_svg_reference?,
                       :external_svg_url?,
                       :style_contains_disallowed_urls?,
                       :svg_url_function_disallowed?,
                       :svg_dimensions,
                       :svg_length_to_pixels

  def parse_original_created_value field
    raw_value = public_send("#{ field }_before_type_cast")
    value = public_send(field)
    return nil if raw_value.blank? && value.blank?

    time =
        case raw_value
        when String
          parse_original_created_string(raw_value)
        when Time, ActiveSupport::TimeWithZone
          raw_value.in_time_zone
        else
          value&.in_time_zone
        end
    if time.nil?
      errors.add field, ORIGINAL_CREATED_INVALID_MESSAGE
      return nil
    end
    unless minute_precision_time?(time)
      errors.add field, ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE
      return nil
    end
    time
  end

  def parse_original_created_string raw_value
    value = raw_value.to_s.strip
    return nil if value.blank?

    match = value.match(/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})\z/)
    if match
      year = match[1].to_i
      month = match[2].to_i
      day = match[3].to_i
      hour = match[4].to_i
      minute = match[5].to_i
      return nil unless valid_original_created_components?(year, month, day, hour, minute, 0)

      return Time.zone.local(year, month, day, hour, minute)
    end

    match =
        value.match(
            /
              \A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})
              (?::(\d{2})(?:\.(\d+))?)?
              (Z|[+-]\d{2}:?\d{2})?
              \z
            /x)
    return nil if match.nil?

    year = match[1].to_i
    month = match[2].to_i
    day = match[3].to_i
    hour = match[4].to_i
    minute = match[5].to_i
    second = match[6]&.to_i || 0
    fraction = match[7]
    offset = match[8]
    return nil unless valid_original_created_components?(year, month, day, hour, minute, second)
    return nil if offset.present? && !(valid_original_created_offset?(offset))

    if offset.present?
      return Time.new(
          year,
          month,
          day,
          hour,
          minute,
          second + Rational(parse_original_created_nanoseconds(fraction), 1_000_000_000),
          normalise_original_created_offset(offset)).in_time_zone
    end

    Time.zone.local(
        year,
        month,
        day,
        hour,
        minute,
        second).change(nsec: parse_original_created_nanoseconds(fraction))
  rescue ArgumentError, TypeError
    nil
  end

  def minute_precision_time? value
    value.sec.zero? && value.nsec.zero?
  end

  def valid_original_created_components? year, month, day, hour, minute, second
    return false unless Date.valid_date?(year, month, day)
    return false unless hour.between?(0, 23)
    return false unless minute.between?(0, 59)
    return false unless second.between?(0, 59)

    true
  end

  def valid_original_created_offset? value
    match = value.match(/\A([+-])(\d{2}):?(\d{2})\z/)
    return true if value == 'Z'
    return false if match.nil?

    hours = match[2].to_i
    minutes = match[3].to_i
    hours.between?(0, 23) && minutes.between?(0, 59)
  end

  def parse_original_created_nanoseconds value
    return 0 if value.blank?

    digits = value[0, 9].ljust(9, '0')
    Integer(digits, 10)
  end

  def normalise_original_created_offset value
    return '+00:00' if value == 'Z'

    value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
  end

  def skip_original_created_validation?
    return false if new_record?
    return false if will_save_change_to_original_created_from?
    return false if will_save_change_to_original_created_before?

    true
  end
end
