lib/attio/api_resource.rb



# frozen_string_literal: true

module Attio
  # Base class for all API resources
  # Provides standard CRUD operations in a clean, Ruby-like way
  class APIResource
    include Enumerable

    attr_reader :id, :created_at, :metadata

    # Keys to skip when processing attributes from API responses
    SKIP_KEYS = %i[id created_at _metadata].freeze

    def initialize(attributes = {}, opts = {})
      @attributes = {}
      @original_attributes = {}
      @changed_attributes = Set.new
      @opts = opts
      @metadata = {}

      # Normalize attributes to use symbol keys
      normalized_attrs = normalize_attributes(attributes)

      # Extract metadata and system fields
      if normalized_attrs.is_a?(Hash)
        # Handle Attio's nested ID structure
        @id = extract_id(normalized_attrs[:id])
        @created_at = parse_timestamp(normalized_attrs[:created_at])
        @metadata = normalized_attrs[:_metadata] || {}

        # Process all attributes
        normalized_attrs.each do |key, value|
          next if SKIP_KEYS.include?(key)

          @attributes[key] = process_attribute_value(value)
          @original_attributes[key] = deep_copy(process_attribute_value(value))
        end
      end
    end

    # Attribute access
    # @param key [String, Symbol] The attribute key to retrieve
    # @return [Object] The value of the attribute
    def [](key)
      @attributes[key.to_sym]
    end

    # Set an attribute value and track changes
    # @param key [String, Symbol] The attribute key to set
    # @param value [Object] The value to set
    def []=(key, value)
      key = key.to_sym
      old_value = @attributes[key]
      new_value = process_attribute_value(value)

      return if old_value == new_value

      @attributes[key] = new_value
      @changed_attributes.add(key)
    end

    # Fetch an attribute value with an optional default
    # @param key [String, Symbol] The attribute key to fetch
    # @param default [Object] The default value if key is not found
    # @return [Object] The attribute value or default
    def fetch(key, default = nil)
      @attributes.fetch(key.to_sym, default)
    end

    def key?(key)
      @attributes.key?(key.to_sym)
    end
    alias_method :has_key?, :key?
    alias_method :include?, :key?

    # Dirty tracking
    def changed?
      !@changed_attributes.empty?
    end

    # Get list of changed attribute names
    # @return [Array<String>] Array of changed attribute names as strings
    def changed
      @changed_attributes.map(&:to_s)
    end

    # Get changes with before and after values
    # @return [Hash] Hash mapping attribute names to [old_value, new_value] arrays
    def changes
      @changed_attributes.each_with_object({}) do |key, hash|
        hash[key.to_s] = [@original_attributes[key], @attributes[key]]
      end
    end

    # Get only the changed attributes and their new values
    # @return [Hash] Hash of changed attributes with their current values
    def changed_attributes
      @changed_attributes.each_with_object({}) do |key, hash|
        hash[key] = @attributes[key]
      end
    end

    # Clear all tracked changes and update original attributes
    # @return [void]
    def reset_changes!
      @changed_attributes.clear
      @original_attributes = deep_copy(@attributes)
    end

    # Revert all changes back to original attribute values
    # @return [void]
    def revert!
      @attributes = deep_copy(@original_attributes)
      @changed_attributes.clear
    end

    # Serialization
    def to_h
      {
        id: id,
        created_at: created_at&.iso8601,
        **@attributes
      }.compact
    end
    alias_method :to_hash, :to_h

    # Convert resource to JSON string
    # @param opts [Hash] Options to pass to JSON.generate
    # @return [String] JSON representation of the resource
    def to_json(*opts)
      JSON.generate(to_h, *opts)
    end

    # Human-readable representation of the resource
    # @return [String] Inspection string with class name, ID, and attributes
    def inspect
      attrs = @attributes.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
      "#<#{self.class.name}:#{object_id} id=#{id.inspect} #{attrs}>"
    end

    # Enumerable support
    def each(&)
      return enum_for(:each) unless block_given?
      @attributes.each(&)
    end

    # Get all attribute keys
    # @return [Array<Symbol>] Array of attribute keys as symbols
    def keys
      @attributes.keys
    end

    # Get all attribute values
    # @return [Array] Array of attribute values
    def values
      @attributes.values
    end

    # Comparison
    def ==(other)
      other.is_a?(self.class) && id == other.id && @attributes == other.instance_variable_get(:@attributes)
    end
    alias_method :eql?, :==

    # Generate hash code for use in Hash keys and Set members
    # @return [Integer] Hash code based on class, ID, and attributes
    def hash
      [self.class, id, @attributes].hash
    end

    # Update attributes
    def update_attributes(attributes)
      attributes.each do |key, value|
        self[key] = value
      end
      self
    end

    # Check if resource has been persisted
    def persisted?
      !id.nil?
    end

    # Update from API response
    def update_from(response)
      normalized = normalize_attributes(response)
      @id = normalized[:id] if normalized[:id]
      @created_at = parse_timestamp(normalized[:created_at]) if normalized[:created_at]

      normalized.each do |key, value|
        next if SKIP_KEYS.include?(key)
        @attributes[key] = process_attribute_value(value)
      end

      reset_changes!
      self
    end

    # Resource path helpers
    # Get the base API path for this resource type
    # @return [String] The API path (e.g., "/v2/objects")
    # @raise [NotImplementedError] Must be implemented by subclasses
    def self.resource_path
      raise NotImplementedError, "Subclasses must implement resource_path"
    end

    # Get the resource name derived from the class name
    # @return [String] The lowercase resource name
    def self.resource_name
      name.split("::").last.downcase
    end

    # Get the full API path for this specific resource instance
    # @return [String] The full API path including the resource ID
    def resource_path
      "#{self.class.resource_path}/#{id}"
    end

    # Default save implementation
    def save(**)
      if persisted?
        self.class.update(id, changed_attributes, **)
      else
        raise InvalidRequestError, "Cannot save a resource without an ID"
      end
    end

    # Default destroy implementation
    def destroy(**)
      raise InvalidRequestError, "Cannot destroy a resource without an ID" unless persisted?
      self.class.delete(id, **)
      true
    end
    alias_method :delete, :destroy

    class << self
      # Define which operations this resource supports
      # Example: api_operations :list, :create, :retrieve, :update, :delete
      def api_operations(*operations)
        @supported_operations = operations

        operations.each do |operation|
          case operation
          when :list
            define_list_operation
          when :create
            define_create_operation
          when :retrieve
            define_retrieve_operation
          when :update
            define_update_operation
          when :delete
            define_delete_operation
          else
            raise ArgumentError, "Unknown operation: #{operation}"
          end
        end
      end

      # Define attribute accessors for known attributes
      def attr_attio(*attributes)
        attributes.each do |attr|
          # Reader method
          define_method(attr) do
            self[attr]
          end

          # Writer method
          define_method("#{attr}=") do |value|
            self[attr] = value
          end

          # Predicate method
          define_method("#{attr}?") do
            !!self[attr]
          end
        end
      end

      # Execute HTTP request
      def execute_request(method, path, params = {}, opts = {})
        client = Attio.client(api_key: opts[:api_key])

        case method
        when :GET
          client.get(path, params)
        when :POST
          client.post(path, params)
        when :PUT
          client.put(path, params)
        when :PATCH
          client.patch(path, params)
        when :DELETE
          client.delete(path)
        else
          raise ArgumentError, "Unsupported method: #{method}"
        end
      end

      # Get the ID parameter name (usually "id", but sometimes needs prefix)
      def id_param_name(id = nil)
        :id
      end

      # Validate an ID parameter
      def validate_id!(id)
        raise ArgumentError, "ID is required" if id.nil? || id.to_s.empty?
      end

      # Hook for subclasses to prepare params before create
      def prepare_params_for_create(params)
        params
      end

      # Hook for subclasses to prepare params before update
      def prepare_params_for_update(params)
        params
      end

      private

      def define_list_operation
        define_singleton_method :list do |params = {}, **opts|
          response = execute_request(:GET, resource_path, params, opts)
          ListObject.new(response, self, params, opts)
        end

        singleton_class.send(:alias_method, :all, :list)
      end

      def define_create_operation
        define_singleton_method :create do |params = {}, **opts|
          prepared_params = prepare_params_for_create(params)
          response = execute_request(:POST, resource_path, prepared_params, opts)
          new(response["data"] || response, opts)
        end
      end

      def define_retrieve_operation
        define_singleton_method :retrieve do |id, **opts|
          validate_id!(id)
          response = execute_request(:GET, "#{resource_path}/#{id}", {}, opts)
          new(response["data"] || response, opts)
        end

        singleton_class.send(:alias_method, :get, :retrieve)
        singleton_class.send(:alias_method, :find, :retrieve)
      end

      def define_update_operation
        define_singleton_method :update do |id, params = {}, **opts|
          validate_id!(id)
          prepared_params = prepare_params_for_update(params)
          response = execute_request(:PATCH, "#{resource_path}/#{id}", prepared_params, opts)
          new(response[:data] || response, opts)
        end
      end

      def define_delete_operation
        define_singleton_method :delete do |id, **opts|
          validate_id!(id)
          execute_request(:DELETE, "#{resource_path}/#{id}", {}, opts)
          true
        end

        singleton_class.send(:alias_method, :destroy, :delete)
      end
    end

    # ListObject for handling paginated responses
    # Container for API list responses with pagination support
    class ListObject
      include Enumerable

      attr_reader :data, :has_more, :cursor, :resource_class

      def initialize(response, resource_class, params = {}, opts = {})
        @resource_class = resource_class
        @params = params
        @opts = opts
        @data = []
        @has_more = false
        @cursor = nil

        if response.is_a?(Hash)
          raw_data = response["data"] || []
          @data = raw_data.map { |attrs| resource_class.new(attrs, opts) }
          @has_more = response["has_more"] || false
          @cursor = response["cursor"]
        end
      end

      # Iterate over each resource in the current page
      # @yield [APIResource] Each resource instance
      # @return [Enumerator] If no block given
      def each(&)
        @data.each(&)
      end

      def empty?
        @data.empty?
      end

      # Get the number of items in the current page
      # @return [Integer] Number of items
      def length
        @data.length
      end
      alias_method :size, :length
      alias_method :count, :length

      # Get the first item in the current page
      # @return [APIResource, nil] The first resource or nil if empty
      def first
        @data.first
      end

      # Get the last item in the current page
      # @return [APIResource, nil] The last resource or nil if empty
      def last
        @data.last
      end

      # Access item by index
      # @param index [Integer] The index of the item to retrieve
      # @return [APIResource, nil] The resource at the given index
      def [](index)
        @data[index]
      end

      # Fetch the next page of results
      # @return [ListObject, nil] The next page or nil if no more pages
      def next_page
        return nil unless has_more? && cursor

        @resource_class.list(@params.merge(cursor: cursor), **@opts)
      end

      def has_more?
        @has_more == true
      end

      # Automatically fetch and iterate through all pages
      # @yield [APIResource] Each resource across all pages
      # @return [Enumerator] If no block given
      def auto_paging_each(&block)
        return enum_for(:auto_paging_each) unless block_given?

        page = self
        loop do
          page.each(&block)
          break unless page.has_more?
          page = page.next_page
          break unless page
        end
      end

      # Convert current page to array
      # @return [Array<APIResource>] Array of resources in current page
      def to_a
        @data
      end

      # Human-readable representation of the list
      # @return [String] Inspection string with data and pagination info
      def inspect
        "#<#{self.class.name} data=#{@data.inspect} has_more=#{@has_more}>"
      end
    end

    protected

    def normalize_attributes(attributes)
      return attributes unless attributes.is_a?(Hash)
      attributes.transform_keys(&:to_sym)
    end

    def process_attribute_value(value)
      case value
      when Hash
        if value.key?(:value) || value.key?("value")
          # Handle Attio attribute format
          value[:value] || value["value"]
        else
          # Regular hash
          value.transform_keys(&:to_sym)
        end
      when Array
        value.map { |v| process_attribute_value(v) }
      else
        value
      end
    end

    def parse_timestamp(value)
      return nil if value.nil?

      case value
      when Time
        value
      when String
        Time.parse(value)
      when Integer
        Time.at(value)
      end
    rescue ArgumentError
      nil
    end

    def extract_id(id_value)
      case id_value
      when Hash
        # Handle Attio's nested ID structure
        # Objects have { workspace_id: "...", object_id: "..." }
        # Records have { workspace_id: "...", object_id: "...", record_id: "..." }
      when String
        # Simple string ID
      end
      id_value
    end

    def deep_copy(obj)
      case obj
      when Hash
        obj.transform_values { |v| deep_copy(v) }
      when Array
        obj.map { |v| deep_copy(v) }
      when Set
        Set.new(obj.map { |v| deep_copy(v) })
      else
        begin
          obj.dup
        rescue
          obj
        end
      end
    end
  end
end