# typed: strict
class Domain::PostFile::BitFingerprint < ReduxApplicationRecord
  include AttrJsonRecordAliases
  self.table_name = "domain_post_file_bit_fingerprints"

  belongs_to :post_file,
             class_name: "::Domain::PostFile",
             inverse_of: :bit_fingerprints,
             touch: true

  belongs_to :thumbnail, class_name: "::Domain::PostFile::Thumbnail"

  validates :fingerprint_value, presence: true

  # in bytes
  HASH_SIZE_BYTES = 32
  DETAIL_HASH_SIZE_BYTES = 128

  # Find similar images based on the fingerprint
  sig { params(fingerprint: String).returns(ActiveRecord::Relation) }
  def self.order_by_fingerprint_distance(fingerprint)
    order(
      Arel.sql(
        "(fingerprint_value <~> '#{ActiveRecord::Base.connection.quote_string(fingerprint)}') ASC",
      ),
    )
  end

  sig do
    params(post_file: Domain::PostFile).returns(
      T::Array[Domain::PostFile::BitFingerprint],
    )
  end
  def self.create_for_post_file!(post_file)
    logger.tagged("fingerprint", make_arg_tag(post_file)) do
      thumbnails = post_file.thumbnails.where(thumb_type: :size_32_32).to_a
      existing_bit_fingerprints = post_file.bit_fingerprints.to_a

      thumbnails_missing_fingerprints =
        thumbnails.reject do |thumbnail|
          existing_bit_fingerprints.any? do |existing_bit_fingerprint|
            existing_bit_fingerprint.thumbnail_id == thumbnail.id
          end
        end

      logger.info(
        format_tags(
          make_tag(
            "thumbnails_missing_fingerprints",
            thumbnails_missing_fingerprints.map(&:id),
          ),
        ),
      )

      thumbnails_missing_fingerprints
        .map do |thumbnail|
          logger.tagged(make_tag("thumbnail", thumbnail.id)) do
            unless thumbnail_path = thumbnail.absolute_file_path
              logger.info(format_tags("unable to compute thumbnail path"))
              next
            end
            fingerprint_value = from_file_path(thumbnail_path)
            fingerprint_detail_value = detail_from_file_path(thumbnail_path)
            fingerprint =
              post_file.bit_fingerprints.build(
                thumbnail:,
                fingerprint_value:,
                fingerprint_detail_value:,
              )
            logger.info(
              format_tags(
                "computed fingerprint #{fingerprint_value.to_i(2).to_s(16).upcase}",
              ),
            )
            fingerprint.save!
            fingerprint
          end
        end
        .compact
    end
  end

  # Calculate the Hamming distance between this fingerprint and another fingerprint
  # @param other_fingerprint [Domain::PostFileFingerprint] The fingerprint to compare with
  # @return [Integer, nil] The Hamming distance (number of differing bits) or nil if either fingerprint is invalid
  sig do
    params(
      other_fingerprint: T.nilable(Domain::PostFile::BitFingerprint),
    ).returns(T.nilable(Integer))
  end
  def hamming_distance_to(other_fingerprint)
    return nil unless (this_hash = self.fingerprint_value)
    return nil unless (other_hash = other_fingerprint&.fingerprint_value)
    self.class.hamming_distance(this_hash, other_hash)
  end

  # Calculate the Hamming distance between two hash values
  # @param hash_value1 [String] The first hash value
  # @param hash_value2 [String] The second hash value
  # @return [Integer, nil] The Hamming distance (number of differing bits) or nil if either hash value is invalid
  sig do
    params(hash_value1: String, hash_value2: String).returns(T.nilable(Integer))
  end
  def self.hamming_distance(hash_value1, hash_value2)
    hash_value1.chars.zip(hash_value2.chars).count { |c1, c2| c1 != c2 }
  end

  # Calculate the similarity percentage between this fingerprint and another fingerprint
  # @param other_fingerprint [Domain::PostFileFingerprint] The fingerprint to compare with
  # @return [Float, nil] The similarity percentage between 0 and 100 or nil if either fingerprint is invalid
  sig do
    params(
      other_fingerprint: T.nilable(Domain::PostFile::BitFingerprint),
    ).returns(T.nilable(Float))
  end
  def similarity_percentage_to(other_fingerprint)
    return nil unless (distance = hamming_distance_to(other_fingerprint))

    # Maximum possible distance for a 256-bit hash
    max_distance = HASH_SIZE_BYTES * 8
    # Calculate similarity percentage based on distance
    result = ((max_distance - distance) / max_distance.to_f * 100).round(1)
    # Ensure the return type is Float
    Float(result)
  end

  # Create a fingerprint from a file path
  # @param file_path [String] Path to the image file
  # @return [String] A non-persisted fingerprint model
  sig { params(file_path: String).returns(String) }
  def self.from_file_path(file_path)
    unless File.exist?(file_path)
      raise ArgumentError, "File does not exist: #{file_path}"
    end

    fingerprint = DHashVips::IDHash.fingerprint(file_path)
    fingerprint.to_s(2).rjust(HASH_SIZE_BYTES * 8, "0")
  end

  # Compute detail fingerprint from a file path
  # @param file_path [String] Path to the image file
  # @return [String] A non-persisted fingerprint model
  sig { params(file_path: String).returns(String) }
  def self.detail_from_file_path(file_path)
    unless File.exist?(file_path)
      raise ArgumentError, "File does not exist: #{file_path}"
    end

    fingerprint = DHashVips::IDHash.fingerprint(file_path, 4)
    fingerprint.to_s(2).rjust(DETAIL_HASH_SIZE_BYTES * 8, "0")
  end
end
