module Lockbox
  module Model
    def has_encrypted(*attributes, **options)
      # support objects
      # case options[:type]
      # when Date
      #   options[:type] = :date
      # when Time
      #   options[:type] = :datetime
      # when JSON
      #   options[:type] = :json
      # when Hash
      #   options[:type] = :hash
      # when Array
      #   options[:type] = :array
      # when String
      #   options[:type] = :string
      # when Integer
      #   options[:type] = :integer
      # when Float
      #   options[:type] = :float
      # when BigDecimal
      #   options[:type] = :decimal
      # end

      custom_type = options[:type].respond_to?(:serialize) && options[:type].respond_to?(:deserialize)
      valid_types = [nil, :string, :boolean, :date, :datetime, :time, :integer, :float, :decimal, :binary, :json, :hash, :array, :inet]
      raise ArgumentError, "Unknown type: #{options[:type]}" unless custom_type || valid_types.include?(options[:type])

      activerecord = defined?(ActiveRecord::Base) && self < ActiveRecord::Base
      raise ArgumentError, "Type not supported yet with Mongoid" if options[:type] && !activerecord

      raise ArgumentError, "No attributes specified" if attributes.empty?

      raise ArgumentError, "Cannot use key_attribute with multiple attributes" if options[:key_attribute] && attributes.size > 1

      original_options = options.dup

      attributes.each do |name|
        # per attribute options
        # TODO use a different name
        options = original_options.dup

        # add default options
        encrypted_attribute = options.delete(:encrypted_attribute) || "#{name}_ciphertext"

        # migrating
        original_name = name.to_sym
        name = "migrated_#{name}" if options[:migrating]

        name = name.to_sym

        options[:attribute] = name.to_s
        options[:encrypted_attribute] = encrypted_attribute
        options[:encode] = Lockbox.encode_attributes unless options.key?(:encode)

        encrypt_method_name = "generate_#{encrypted_attribute}"
        decrypt_method_name = "decrypt_#{encrypted_attribute}"

        class_eval do
          # Lockbox uses custom inspect
          # but this could be useful for other gems
          if activerecord
            # only add virtual attribute
            # need to use regexp since strings do partial matching
            # also, need to use += instead of <<
            self.filter_attributes += [/\A#{Regexp.escape(options[:attribute])}\z/]
          end

          @lockbox_attributes ||= {}

          if @lockbox_attributes.empty?
            def self.lockbox_attributes
              parent_attributes =
                if superclass.respond_to?(:lockbox_attributes)
                  superclass.lockbox_attributes
                else
                  {}
                end

              parent_attributes.merge(@lockbox_attributes || {})
            end

            # use same approach as activerecord serialization
            def serializable_hash(options = nil)
              options = options.try(:dup) || {}

              options[:except] = Array(options[:except])
              options[:except] += self.class.lockbox_attributes.flat_map { |_, v| [v[:attribute], v[:encrypted_attribute]] }

              super(options)
            end

            # maintain order
            # replace ciphertext attributes w/ virtual attributes (filtered)
            def inspect
              lockbox_attributes = {}
              lockbox_encrypted_attributes = {}
              self.class.lockbox_attributes.each do |_, lockbox_attribute|
                lockbox_attributes[lockbox_attribute[:attribute]] = true
                lockbox_encrypted_attributes[lockbox_attribute[:encrypted_attribute]] = lockbox_attribute[:attribute]
              end

              inspection = []
              # use serializable_hash like Devise
              values = serializable_hash
              self.class.attribute_names.each do |k|
                next if !has_attribute?(k) || lockbox_attributes[k]

                # check for lockbox attribute
                if lockbox_encrypted_attributes[k]
                  # check if ciphertext attribute nil to avoid loading attribute
                  v = send(k).nil? ? "nil" : "[FILTERED]"
                  k = lockbox_encrypted_attributes[k]
                elsif values.key?(k)
                  v = respond_to?(:attribute_for_inspect) ? attribute_for_inspect(k) : values[k].inspect
                else
                  next
                end

                inspection << "#{k}: #{v}"
              end

              "#<#{self.class} #{inspection.join(", ")}>"
            end

            if activerecord
              # TODO wrap in module?
              def attributes
                # load attributes
                # essentially a no-op if already loaded
                # an exception is thrown if decryption fails
                self.class.lockbox_attributes.each do |_, lockbox_attribute|
                  # it is possible that the encrypted attribute is not loaded, eg.
                  # if the record was fetched partially (`User.select(:id).first`).
                  # accessing a not loaded attribute raises an `ActiveModel::MissingAttributeError`.
                  if has_attribute?(lockbox_attribute[:encrypted_attribute])
                    begin
                      send(lockbox_attribute[:attribute])
                    rescue ArgumentError => e
                      raise e if e.message != "No decryption key set"
                    end
                  end
                end

                # remove attributes that do not have a ciphertext attribute
                attributes = super
                self.class.lockbox_attributes.each do |k, lockbox_attribute|
                  if !attributes.include?(lockbox_attribute[:encrypted_attribute].to_s)
                    attributes.delete(k.to_s)
                    attributes.delete(lockbox_attribute[:attribute])
                  end
                end
                attributes
              end

              # remove attribute names that do not have a ciphertext attribute
              def attribute_names
                # hash preserves key order
                names_set = super.to_h { |v| [v, true] }
                self.class.lockbox_attributes.each do |k, lockbox_attribute|
                  if !names_set.include?(lockbox_attribute[:encrypted_attribute].to_s)
                    names_set.delete(k.to_s)
                    names_set.delete(lockbox_attribute[:attribute])
                  end
                end
                names_set.keys
              end

              # check the ciphertext attribute for encrypted attributes
              def has_attribute?(attr_name)
                attr_name = attr_name.to_s
                _, lockbox_attribute = self.class.lockbox_attributes.find { |_, la| la[:attribute] == attr_name }
                if lockbox_attribute
                  super(lockbox_attribute[:encrypted_attribute])
                else
                  super
                end
              end

              # needed for in-place modifications
              # assigned attributes are encrypted on assignment
              # and then again here
              def lockbox_sync_attributes
                self.class.lockbox_attributes.each do |_, lockbox_attribute|
                  attribute = lockbox_attribute[:attribute]

                  if attribute_changed_in_place?(attribute) || (send("#{attribute}_changed?") && !send("#{lockbox_attribute[:encrypted_attribute]}_changed?"))
                    send("#{attribute}=", send(attribute))
                  end
                end
              end

              # safety check
              [:_create_record, :_update_record].each do |method_name|
                unless private_method_defined?(method_name) || method_defined?(method_name)
                  raise Lockbox::Error, "Expected #{method_name} to be defined. Please report an issue."
                end
              end

              def _create_record(*)
                lockbox_sync_attributes
                super
              end

              def _update_record(*)
                lockbox_sync_attributes
                super
              end

              def [](attr_name)
                send(attr_name) if self.class.lockbox_attributes.any? { |_, la| la[:attribute] == attr_name.to_s }
                super
              end

              def update_columns(attributes)
                return super unless attributes.is_a?(Hash)

                # transform keys like Active Record
                attributes = attributes.transform_keys do |key|
                  n = key.to_s
                  self.class.attribute_aliases[n] || n
                end

                lockbox_attributes = self.class.lockbox_attributes.slice(*attributes.keys.map(&:to_sym))
                return super unless lockbox_attributes.any?

                attributes_to_set = {}

                lockbox_attributes.each do |key, lockbox_attribute|
                  attribute = key.to_s
                  # check read only
                  verify_readonly_attribute(attribute)

                  message = attributes[attribute]
                  attributes.delete(attribute) unless lockbox_attribute[:migrating]
                  encrypted_attribute = lockbox_attribute[:encrypted_attribute]
                  ciphertext = self.class.send("generate_#{encrypted_attribute}", message, context: self)
                  attributes[encrypted_attribute] = ciphertext
                  attributes_to_set[attribute] = message
                  attributes_to_set[lockbox_attribute[:attribute]] = message if lockbox_attribute[:migrating]
                end

                result = super(attributes)

                # same logic as Active Record
                # (although this happens before saving)
                attributes_to_set.each do |k, v|
                  if respond_to?(:write_attribute_without_type_cast, true)
                    write_attribute_without_type_cast(k, v)
                  elsif respond_to?(:raw_write_attribute, true)
                    raw_write_attribute(k, v)
                  else
                    @attributes.write_cast_value(k, v)
                    clear_attribute_change(k)
                  end
                end

                result
              end

              if ActiveRecord::VERSION::STRING.to_f >= 7.2
                def self.insert(attributes, **options)
                  super(lockbox_map_record_attributes(attributes), **options)
                end

                def self.insert!(attributes, **options)
                  super(lockbox_map_record_attributes(attributes), **options)
                end

                def self.upsert(attributes, **options)
                  super(lockbox_map_record_attributes(attributes, check_readonly: true), **options)
                end
              end

              def self.insert_all(attributes, **options)
                super(lockbox_map_attributes(attributes), **options)
              end

              def self.insert_all!(attributes, **options)
                super(lockbox_map_attributes(attributes), **options)
              end

              def self.upsert_all(attributes, **options)
                super(lockbox_map_attributes(attributes, check_readonly: true), **options)
              end

              # private
              # does not try to handle :returning option for simplicity
              def self.lockbox_map_attributes(records, check_readonly: false)
                return records unless records.is_a?(Array)

                records.map do |attributes|
                  lockbox_map_record_attributes(attributes, check_readonly: false)
                end
              end

              # private
              def self.lockbox_map_record_attributes(attributes, check_readonly: false)
                return attributes unless attributes.is_a?(Hash)

                # transform keys like Active Record
                attributes = attributes.transform_keys do |key|
                  n = key.to_s
                  attribute_aliases[n] || n
                end

                lockbox_attributes = self.lockbox_attributes.slice(*attributes.keys.map(&:to_sym))
                lockbox_attributes.each do |key, lockbox_attribute|
                  attribute = key.to_s
                  # check read only
                  # users should mark both plaintext and ciphertext columns
                  if check_readonly && readonly_attributes.include?(attribute) && !readonly_attributes.include?(lockbox_attribute[:encrypted_attribute].to_s)
                    warn "[lockbox] WARNING: Mark attribute as readonly: #{lockbox_attribute[:encrypted_attribute]}"
                  end

                  message = attributes[attribute]
                  attributes.delete(attribute) unless lockbox_attribute[:migrating]
                  encrypted_attribute = lockbox_attribute[:encrypted_attribute]
                  ciphertext = send("generate_#{encrypted_attribute}", message)
                  attributes[encrypted_attribute] = ciphertext
                end

                attributes
              end
            else
              def reload
                self.class.lockbox_attributes.each do |_, v|
                  instance_variable_set("@#{v[:attribute]}", nil)
                end
                super
              end
            end
          end

          raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name]
          raise "Multiple encrypted attributes use the same column: #{encrypted_attribute}" if lockbox_attributes.any? { |_, v| v[:encrypted_attribute] == encrypted_attribute }
          @lockbox_attributes[original_name] = options

          if activerecord
            # warn on store attributes
            if stored_attributes.any? { |k, v| v.include?(name) }
              warn "[lockbox] WARNING: encrypting store accessors is not supported. Encrypt the column instead."
            end

            # warn on default attributes
            if ActiveRecord::VERSION::STRING.to_f >= 7.2
              # TODO improve
              if pending_attribute_modifications.any? { |v| v.is_a?(ActiveModel::AttributeRegistration::ClassMethods::PendingDefault) && v.name == name.to_s }
                warn "[lockbox] WARNING: attributes with `:default` option are not supported. Use `after_initialize` instead."
              end
            elsif attributes_to_define_after_schema_loads.key?(name.to_s)
              opt = attributes_to_define_after_schema_loads[name.to_s][1]

              # not ideal, since NO_DEFAULT_PROVIDED is private
              has_default = opt != ActiveRecord::Attributes::ClassMethods.const_get(:NO_DEFAULT_PROVIDED)

              if has_default
                warn "[lockbox] WARNING: attributes with `:default` option are not supported. Use `after_initialize` instead."
              end
            end

            # preference:
            # 1. type option
            # 2. existing virtual attribute
            # 3. default to string (which can later be overridden)
            if options[:type]
              attribute_type =
                case options[:type]
                when :json, :hash, :array
                  :string
                when :integer
                  ActiveModel::Type::Integer.new(limit: 8)
                else
                  options[:type]
                end

              attribute name, attribute_type

              case options[:type]
              when :json
                serialize name, coder: JSON
              when :hash
                serialize name, type: Hash, coder: default_column_serializer || YAML
              when :array
                serialize name, type: Array, coder: default_column_serializer || YAML
              end
            elsif ActiveRecord::VERSION::STRING.to_f >= 7.2
              decorate_attributes([name]) do |attr_name, cast_type|
                if cast_type.instance_of?(ActiveRecord::Type::Value)
                  original_type = pending_attribute_modifications.find { |v| v.is_a?(ActiveModel::AttributeRegistration::ClassMethods::PendingType) && v.name == original_name.to_s && !v.type.nil? }&.type
                  if original_type
                    original_type
                  elsif options[:migrating]
                    cast_type
                  else
                    ActiveRecord::Type::String.new
                  end
                elsif cast_type.is_a?(ActiveRecord::Type::Serialized) && cast_type.subtype.instance_of?(ActiveModel::Type::Value)
                  # hack to set string type after serialize
                  # otherwise, type gets set to ActiveModel::Type::Value
                  # which always returns false for changed_in_place?
                  ActiveRecord::Type::Serialized.new(ActiveRecord::Type::String.new, cast_type.coder)
                else
                  cast_type
                end
              end
            elsif !attributes_to_define_after_schema_loads.key?(name.to_s)
              # when migrating it's best to specify the type directly
              # however, we can try to use the original type if its already defined
              if attributes_to_define_after_schema_loads.key?(original_name.to_s)
                attribute name, attributes_to_define_after_schema_loads[original_name.to_s].first
              elsif options[:migrating]
                # we use the original attribute for serialization in the encrypt and decrypt methods
                # so we can use a generic value here
                attribute name, ActiveRecord::Type::Value.new
              else
                attribute name, :string
              end
            elsif attributes_to_define_after_schema_loads[name.to_s].first.is_a?(Proc)
              # hack for Active Record 6.1+ to set string type after serialize
              # otherwise, type gets set to ActiveModel::Type::Value
              # which always returns false for changed_in_place?
              # earlier versions of Active Record take the previous code path
              attribute_type = attributes_to_define_after_schema_loads[name.to_s].first.call(nil)
              if attribute_type.is_a?(ActiveRecord::Type::Serialized) && attribute_type.subtype.nil?
                attribute name, ActiveRecord::Type::Serialized.new(ActiveRecord::Type::String.new, attribute_type.coder)
              end
            end

            define_method("#{name}_was") do
              send(name) # writes attribute when not already set
              super()
            end

            # restore ciphertext as well
            define_method("restore_#{name}!") do
              super()
              send("restore_#{encrypted_attribute}!")
            end

            define_method("#{name}_in_database") do
              send(name) # writes attribute when not already set
              super()
            end

            define_method("#{name}?") do
              # uses public_send, so we don't need to preload attribute
              query_attribute(name)
            end
          else
            # keep this module dead simple
            # Mongoid uses changed_attributes to calculate keys to update
            # so we shouldn't mess with it
            m = Module.new do
              define_method("#{name}=") do |val|
                instance_variable_set("@#{name}", val)
              end

              define_method(name) do
                instance_variable_get("@#{name}")
              end
            end

            include m

            alias_method "#{name}_changed?", "#{encrypted_attribute}_changed?"

            define_method "#{name}_was" do
              ciphertext = send("#{encrypted_attribute}_was")
              self.class.send(decrypt_method_name, ciphertext, context: self)
            end

            define_method "#{name}_change" do
              ciphertexts = send("#{encrypted_attribute}_change")
              ciphertexts.map { |v| self.class.send(decrypt_method_name, v, context: self) } if ciphertexts
            end

            define_method "reset_#{name}!" do
              instance_variable_set("@#{name}", nil)
              send("reset_#{encrypted_attribute}!")
              send(name)
            end

            define_method "reset_#{name}_to_default!" do
              instance_variable_set("@#{name}", nil)
              send("reset_#{encrypted_attribute}_to_default!")
              send(name)
            end

            define_method("#{name}?") do
              send("#{encrypted_attribute}?")
            end
          end

          define_method("#{name}=") do |message|
            # decrypt first for dirty tracking
            # don't raise error if can't decrypt previous
            # don't try to decrypt if no decryption key given
            begin
              send(name)
            rescue Lockbox::DecryptionError
              warn "[lockbox] Decrypting previous value failed"
            rescue ArgumentError => e
              raise e if e.message != "No decryption key set"
            end

            send("lockbox_direct_#{name}=", message)

            # warn every time, as this should be addressed
            # maybe throw an error in the future
            if !options[:migrating]
              if activerecord
                if self.class.columns_hash.key?(name.to_s)
                  warn "[lockbox] WARNING: Unencrypted column with same name: #{name}. Set `ignored_columns` or remove it to protect the data."
                end
              else
                if self.class.fields.key?(name.to_s)
                  warn "[lockbox] WARNING: Unencrypted field with same name: #{name}. Remove it to protect the data."
                end
              end
            end

            super(message)
          end

          # separate method for setting directly
          # used to skip blind indexes for key rotation
          define_method("lockbox_direct_#{name}=") do |message|
            ciphertext = self.class.send(encrypt_method_name, message, context: self)
            send("#{encrypted_attribute}=", ciphertext)
          end
          private :"lockbox_direct_#{name}="

          define_method(name) do
            message = super()

            # possibly keep track of decrypted attributes directly in the future
            # Hash serializer returns {} when nil, Array serializer returns [] when nil
            # check for this explicitly as a layer of safety
            if message.nil? || ((message == {} || message == []) && activerecord && @attributes[name.to_s].value_before_type_cast.nil?)
              ciphertext = send(encrypted_attribute)

              # keep original message for empty hashes and arrays
              unless ciphertext.nil?
                message = self.class.send(decrypt_method_name, ciphertext, context: self)
              end

              if activerecord
                # set previous attribute so changes populate correctly
                # it's fine if this is set on future decryptions (as is the case when message is nil)
                # as only the first value is loaded into changes
                @attributes[name.to_s].instance_variable_set("@value_before_type_cast", message)

                # cache
                # decrypt method does type casting
                if respond_to?(:write_attribute_without_type_cast, true)
                  write_attribute_without_type_cast(name.to_s, message) if !@attributes.frozen?
                elsif respond_to?(:raw_write_attribute, true)
                  raw_write_attribute(name, message) if !@attributes.frozen?
                else
                  if !@attributes.frozen?
                    @attributes.write_cast_value(name.to_s, message)
                    clear_attribute_change(name)
                  end
                end

                # ensure same object is returned as next call
                message = super()
              else
                instance_variable_set("@#{name}", message)
              end
            end

            message
          end

          # for fixtures
          define_singleton_method encrypt_method_name do |message, **opts|
            table = activerecord ? table_name : collection_name.to_s

            unless message.nil?
              case options[:type]
              when :boolean
                message = ActiveRecord::Type::Boolean.new.serialize(message)
                message = message ? "t" : "f" unless message.nil?
              when :date
                message = ActiveRecord::Type::Date.new.serialize(message)
                # strftime should be more stable than to_s(:db)
                message = message.strftime("%Y-%m-%d") unless message.nil?
              when :datetime
                message = ActiveRecord::Type::DateTime.new.serialize(message)
                message = message.iso8601(9) unless message.nil?
              when :time
                message = ActiveRecord::Type::Time.new.serialize(message)
                message = nil unless message.respond_to?(:strftime)
                message = message.strftime("%H:%M:%S.%N") unless message.nil?
                message
              when :integer
                message = ActiveRecord::Type::Integer.new(limit: 8).serialize(message)
                message = 0 if message.nil?
                # signed 64-bit integer, big endian
                message = [message].pack("q>")
              when :float
                message = ActiveRecord::Type::Float.new.serialize(message)
                # double precision, big endian
                message = [message].pack("G") unless message.nil?
              when :decimal
                message = ActiveRecord::Type::Decimal.new.serialize(message)
                # Postgres stores 4 decimal digits in 2 bytes
                # plus 3 to 8 bytes of overhead
                # but use string for simplicity
                message = message.to_s("F") unless message.nil?
              when :inet
                unless message.nil?
                  ip = message.is_a?(IPAddr) ? message : (IPAddr.new(message) rescue nil)
                  # same format as Postgres, with ipv4 padded to 16 bytes
                  # family, netmask, ip
                  # return nil for invalid IP like Active Record
                  message = ip ? [ip.ipv4? ? 0 : 1, ip.prefix, ip.hton].pack("CCa16") : nil
                end
              when :string, :binary
                # do nothing
                # encrypt will convert to binary
              else
                # use original name for serialized attributes if no type specified
                type = (try(:attribute_types) || {})[(options[:type] ? name : original_name).to_s]
                message = type.serialize(message) if type
              end
            end

            if message.nil? || (message == "" && !options[:padding])
              message
            else
              Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).encrypt(message)
            end
          end

          define_singleton_method decrypt_method_name do |ciphertext, **opts|
            message =
              if ciphertext.nil? || (ciphertext == "" && !options[:padding])
                ciphertext
              else
                table = activerecord ? table_name : collection_name.to_s
                Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).decrypt(ciphertext)
              end

            unless message.nil?
              case options[:type]
              when :boolean
                message = message == "t"
              when :date
                message = ActiveRecord::Type::Date.new.deserialize(message)
              when :datetime
                message = ActiveRecord::Type::DateTime.new.deserialize(message)
              when :time
                message = ActiveRecord::Type::Time.new.deserialize(message)
              when :integer
                message = ActiveRecord::Type::Integer.new(limit: 8).deserialize(message.unpack1("q>"))
              when :float
                message = ActiveRecord::Type::Float.new.deserialize(message.unpack1("G"))
              when :decimal
                message = ActiveRecord::Type::Decimal.new.deserialize(message)
              when :string
                message.force_encoding(Encoding::UTF_8)
              when :binary
                # do nothing
                # decrypt returns binary string
              when :inet
                family, prefix, addr = message.unpack("CCa16")
                len = family == 0 ? 4 : 16
                message = IPAddr.new_ntoh(addr.first(len))
                message.prefix = prefix
              else
                # use original name for serialized attributes if no type specified
                type = (try(:attribute_types) || {})[(options[:type] ? name : original_name).to_s]
                # for Action Text
                if activerecord && type.is_a?(ActiveRecord::Type::Serialized) && defined?(ActionText::Content) && type.coder == ActionText::Content
                  message.force_encoding(Encoding::UTF_8)
                end
                message = type.deserialize(message) if type
                message.force_encoding(Encoding::UTF_8) if !type || type.is_a?(ActiveModel::Type::String)
              end
            end

            message
          end

          if options[:migrating]
            # TODO reuse module
            m = Module.new do
              define_method "#{original_name}=" do |value|
                result = super(value)
                send("#{name}=", send(original_name))
                result
              end

              unless activerecord
                define_method "reset_#{original_name}!" do
                  result = super()
                  send("#{name}=", send(original_name))
                  result
                end
              end
            end
            prepend m
          end
        end
      end
    end

    module Attached
      def encrypts_attached(*attributes, **options)
        attributes.each do |name|
          name = name.to_sym

          class_eval do
            @lockbox_attachments ||= {}

            if @lockbox_attachments.empty?
              def self.lockbox_attachments
                parent_attachments =
                  if superclass.respond_to?(:lockbox_attachments)
                    superclass.lockbox_attachments
                  else
                    {}
                  end

                parent_attachments.merge(@lockbox_attachments || {})
              end
            end

            raise "Duplicate encrypted attachment: #{name}" if lockbox_attachments[name]
            @lockbox_attachments[name] = options
          end
        end
      end
    end
  end
end
