lib/attio/internal/record.rb



# frozen_string_literal: true

require_relative "../api_resource"

module Attio
  # @api private
  module Internal
    # Base class for record-based resources (Person, Company, etc.)
    # This class handles the complex Attio Record API and should not be used directly.
    # Use Person, Company, or TypedRecord instead.
    #
    # @api private
    class Record < APIResource
      # Record doesn't use standard CRUD operations due to object parameter requirement
      # We'll define custom methods instead
      api_operations :delete

      # API endpoint path for records (nested under objects)
      # @return [String] The API path
      def self.resource_path
        "objects"
      end

      attr_reader :attio_object_id, :object_api_slug

      def initialize(attributes = {}, opts = {})
        super

        normalized_attrs = normalize_attributes(attributes)
        @attio_object_id = normalized_attrs[:object_id]
        @object_api_slug = normalized_attrs[:object_api_slug]

        # Process values into attributes
        if normalized_attrs[:values]
          process_values(normalized_attrs[:values])
        end
      end

      class << self
        # List records for an object
        def list(object:, **opts)
          validate_object_identifier!(object)

          # Extract query parameters from opts
          # Handle both opts[:params] (from find_by) and direct opts (from other callers)
          params = opts[:params] || opts
          query_params = build_query_params(params)

          response = execute_request(:POST, "#{resource_path}/#{object}/records/query", query_params, opts)

          APIResource::ListObject.new(response, self, opts.merge(object: object), opts)
        end
        alias_method :all, :list

        # Create a new record
        def create(object: nil, values: nil, data: nil, **opts)
          # Handle both parameter styles
          if values
            # Test style: create(object: "people", values: {...})
            validate_object_identifier!(object)
            validate_values!(values)
            normalized_values = values
          elsif data && data[:values]
            # API style: create(object: "people", data: { values: {...} })
            validate_object_identifier!(object)
            validate_values!(data[:values])
            normalized_values = data[:values]
          else
            raise ArgumentError, "Must provide object and either values or data.values"
          end

          normalized = normalize_values(normalized_values)
          puts "DEBUG: Normalized values: #{normalized.inspect}" if ENV["ATTIO_DEBUG"]

          request_params = {
            data: {
              values: normalized
            }
          }

          response = execute_request(:POST, "#{resource_path}/#{object}/records", request_params, opts)

          # Ensure object info is included
          record_data = response["data"] || {}
          record_data["object_api_slug"] ||= object if record_data.is_a?(Hash)

          new(record_data, opts)
        end

        # Retrieve a specific record
        def retrieve(record_id: nil, object: nil, **opts)
          validate_object_identifier!(object)

          # Extract simple ID if it's a nested hash
          simple_record_id = record_id.is_a?(Hash) ? record_id["record_id"] : record_id
          validate_id!(simple_record_id)

          response = execute_request(:GET, "#{resource_path}/#{object}/records/#{simple_record_id}", {}, opts)

          record_data = response["data"] || {}
          record_data["object_api_slug"] ||= object if record_data.is_a?(Hash)

          new(record_data, opts)
        end
        alias_method :get, :retrieve
        alias_method :find, :retrieve

        # Update a record
        def update(record_id: nil, object: nil, data: nil, **opts)
          validate_object_identifier!(object)

          # Extract simple ID if it's a nested hash
          simple_record_id = record_id.is_a?(Hash) ? record_id["record_id"] : record_id
          validate_id!(simple_record_id)

          request_params = {
            data: {
              values: normalize_values(data[:values] || data)
            }
          }

          response = execute_request(:PUT, "#{resource_path}/#{object}/records/#{simple_record_id}", request_params, opts)

          record_data = response["data"] || {}
          record_data["object_api_slug"] ||= object if record_data.is_a?(Hash)

          new(record_data, opts)
        end

        # Search records
        # Note: The Attio API doesn't have a search endpoint, so we use filtering
        # This provides a basic search across common text fields
        def search(query, object:, **opts)
          # For now, just pass through to list with the query
          # Subclasses should override this to provide proper search filters
          list(object: object, **opts)
        end

        private

        def validate_object_identifier!(object)
          raise ArgumentError, "Object identifier is required" if object.nil? || object.to_s.empty?
        end

        def validate_values!(values)
          raise ArgumentError, "Values must be a Hash" unless values.is_a?(Hash)
        end

        def build_query_params(params)
          query_params = {}

          query_params[:filter] = build_filter(params[:filter]) if params[:filter]
          query_params[:sort] = build_sort(params[:sort]) if params[:sort]
          query_params[:limit] = params[:limit] if params[:limit]
          query_params[:cursor] = params[:cursor] if params[:cursor]
          # Note: 'q' parameter is not supported by Attio API

          query_params
        end

        def build_filter(filter)
          case filter
          when Hash
            filter
          when Array
            {"$and" => filter}
          else
            filter
          end
        end

        def build_sort(sort)
          case sort
          when String
            parse_sort_string(sort)
          when Hash
            sort
          else
            sort
          end
        end

        def parse_sort_string(sort_string)
          field, direction = sort_string.split(":")
          {
            field: field,
            direction: direction || "asc"
          }
        end

        # Attributes that should be sent as simple arrays of strings or simple values
        SIMPLE_ARRAY_ATTRIBUTES = %w[email_addresses domains].freeze
        SIMPLE_VALUE_ATTRIBUTES = %w[description linkedin job_title employee_count value stage status close_date probability owner].freeze
        # Attributes that are arrays of objects and should be sent as-is
        OBJECT_ARRAY_ATTRIBUTES = %w[phone_numbers primary_location company].freeze

        def normalize_values(values)
          values.map do |key, value|
            # Check if this is a simple array attribute
            if SIMPLE_ARRAY_ATTRIBUTES.include?(key.to_s) && value.is_a?(Array)
              # For email_addresses and domains, keep strings as-is
              [key, value]
            elsif SIMPLE_VALUE_ATTRIBUTES.include?(key.to_s) && !value.is_a?(Hash) && !value.is_a?(Array)
              # For simple string attributes, send directly
              [key, value]
            elsif OBJECT_ARRAY_ATTRIBUTES.include?(key.to_s) && value.is_a?(Array)
              # For arrays of objects like phone_numbers, etc., keep as-is
              [key, value]
            elsif key.to_s == "name"
              # Special handling for name - keep as-is whether string or array
              # Company names are strings, Person names are arrays of objects
              [key, value]
            else
              normalized_value = case value
              when Array
                value.map { |v| normalize_single_value(v) }
              else
                normalize_single_value(value)
              end
              [key, normalized_value]
            end
          end.to_h
        end

        def normalize_single_value(value)
          case value
          when Hash
            value
          when NilClass
            nil
          else
            {value: value}
          end
        end
      end

      # Instance methods

      # Save changes to the record
      def save(**opts)
        raise InvalidRequestError, "Cannot update a record without an ID" unless persisted?
        raise InvalidRequestError, "Cannot save without object context" unless object_api_slug

        return self unless changed?

        params = {
          data: {
            values: prepare_values_for_update
          }
        }

        response = self.class.send(:execute_request, :PATCH, resource_path, params, opts)

        update_from(response[:data] || response)
        reset_changes!
        self
      end

      # Add this record to a list
      def add_to_list(list_id, **)
        list = List.retrieve(list_id, **)
        list.add_record(id, **)
      end

      # Get lists containing this record
      def lists(**)
        raise InvalidRequestError, "Cannot get lists without an ID" unless persisted?

        # This is a simplified implementation - in reality you'd need to query the API
        # for lists that contain this record
        List.list(record_id: id, **)
      end

      def resource_path
        raise InvalidRequestError, "Cannot generate path without object context" unless object_api_slug
        record_id = id.is_a?(Hash) ? id["record_id"] : id
        "#{self.class.resource_path}/#{object_api_slug}/records/#{record_id}"
      end

      # Override destroy to use correct path
      def destroy(**opts)
        raise InvalidRequestError, "Cannot destroy a record without an ID" unless persisted?
        raise InvalidRequestError, "Cannot destroy without object context" unless object_api_slug

        self.class.send(:execute_request, :DELETE, resource_path, {}, opts)
        @attributes.clear
        @changed_attributes.clear
        @id = nil
        freeze
        true
      end

      # Convert record to hash representation
      # @return [Hash] Record data as a hash
      def to_h
        values = @attributes.except(:id, :created_at, :object_id, :object_api_slug)

        {
          id: id,
          object_id: attio_object_id,
          object_api_slug: object_api_slug,
          created_at: created_at&.iso8601,
          values: values
        }.compact
      end

      # Human-readable representation of the record
      # @return [String] Inspection string with ID, object, and sample values
      def inspect
        values_preview = @attributes.take(3).map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
        values_preview += "..." if @attributes.size > 3

        "#<#{self.class.name}:#{object_id} id=#{id.inspect} object=#{object_api_slug.inspect} values={#{values_preview}}>"
      end

      private

      def process_values(values)
        return unless values.is_a?(Hash)

        values.each do |key, value_data|
          extracted_value = extract_value(value_data)
          @attributes[key.to_sym] = extracted_value
          @original_attributes[key.to_sym] = deep_copy(extracted_value)
        end
      end

      def extract_value(value_data)
        case value_data
        when Array
          extracted = value_data.map { |v| extract_single_value(v) }
          (extracted.length == 1) ? extracted.first : extracted
        else
          extract_single_value(value_data)
        end
      end

      def extract_single_value(value_data)
        case value_data
        when Hash
          if value_data.key?(:value) || value_data.key?("value")
            value_data[:value] || value_data["value"]
          elsif value_data.key?(:target_object) || value_data.key?("target_object") ||
                value_data.key?(:referenced_actor_type) || value_data.key?("referenced_actor_type")
            # Reference value - return the full reference object
            value_data
          elsif value_data.key?(:currency_value) || value_data.key?("currency_value")
            # Currency value - return the full object to preserve currency info
            value_data
          elsif value_data.key?(:status) || value_data.key?("status")
            # Status value - return the full object to preserve status info
            value_data
          else
            value_data
          end
        else
          value_data
        end
      end

      def prepare_values_for_update
        changed_attributes.transform_values do |value|
          self.class.send(:normalize_values, {key: value})[:key]
        end
      end
    end
  end
end