# typed: true
# frozen_string_literal: true

require "sorbet-runtime"
require "active_record"
require "active_record/base"
require "active_support"
require "active_support/concern"
require "active_model/attribute_set"

require_relative "has_aux_table/key_type"
require_relative "has_aux_table/util"
require_relative "has_aux_table/relation_extensions"
require_relative "has_aux_table/aux_table_config"
require_relative "has_aux_table/migration_extensions"

module HasAuxTable
  extend T::Sig
  extend T::Helpers
  extend ActiveSupport::Concern

  VERSION = "0.1.0"

  module ClassMethods
    extend T::Sig
    extend T::Helpers
    requires_ancestor { Kernel }
    requires_ancestor { T.class_of(BasicObject) }
    requires_ancestor { T.class_of(ActiveRecord::Base) }
    requires_ancestor { ActiveRecord::ModelSchema::ClassMethods }
    requires_ancestor { ActiveRecord::Associations::ClassMethods }

    include RelationExtensions

    # Main DSL method for defining auxiliary tables
    sig { params(aux_name: T.any(String, Symbol)).returns(AuxTableConfig) }
    def aux_table(aux_name)
      @aux_table_configs ||=
        T.let({}, T.nilable(T::Hash[Symbol, AuxTableConfig]))

      aux_name = aux_name.to_sym
      if @aux_table_configs.key?(aux_name)
        Kernel.raise ArgumentError,
                     "Auxiliary '#{aux_name}' on #{self.name} (table '#{self.table_name}') already exists"
      end

      @aux_table_configs[aux_name] = aux_config = generate_aux_config(aux_name)
      setup_attribute_types_hook!(aux_config)
      setup_schema_loading_hook!(aux_config)
      setup_relation_extensions!(aux_config)

      aux_config
    end

    private

    sig { params(aux_config: AuxTableConfig).void }
    def setup_attribute_types_hook!(aux_config)
      original_method = aux_config.main_class.method(:attribute_types)
      aux_config
        .main_class
        .define_singleton_method(:attribute_types) do
          @aux_config_attribute_types_cache ||= {}
          @aux_config_attribute_types_cache[aux_config.aux_table_name] ||= begin
            original_types = original_method.call.dup

            aux_types =
              aux_config.model_class.attribute_types.filter do |k, _|
                aux_config.is_aux_column?(k)
              end

            original_types.merge!(aux_types)
            original_types
          end
        end
    end

    # Hook into schema loading to generate attribute accessors when schema is loaded
    sig { params(aux_config: AuxTableConfig).void }
    def setup_schema_loading_hook!(aux_config)
      aux_table_name = aux_config.aux_table_name

      # Override load_schema to also generate auxiliary attribute accessors when schema is loaded
      load_schema_method = self.method(:load_schema!)
      self.define_singleton_method(:load_schema!) do
        T.bind(
          self,
          T.all(T.class_of(ActiveRecord::Base), HasAuxTable::ClassMethods)
        )

        # first, load the main and aux table schemas like normal
        result = load_schema_method.call
        aux_config.load_aux_schema

        main_columns_hash = self.columns_hash
        aux_columns_hash =
          aux_config.model_class.columns_hash.select do |col|
            aux_config.is_aux_column?(col)
          end

        main_column_names = main_columns_hash.keys
        aux_column_names = aux_columns_hash.keys

        check_for_overlapping_columns!(
          aux_table_name,
          main_column_names,
          aux_column_names
        )

        aux_attributes = aux_config.model_class._default_attributes
        aux_table_filtered_attributes =
          aux_attributes
            .keys
            .filter_map do |k|
              [k, aux_attributes[k]] if aux_column_names.include?(k)
            end
            .to_h

        # set attributes that exist on the aux table to also exist on this table
        aux_table_filtered_attributes.each do |name, attr|
          @default_attributes[name] = attr
        end

        # Generate attribute accessors for each auxiliary column
        aux_columns_hash.each do |column_name, column|
          column_name = column_name.to_sym
          if self.method_defined?(column_name.to_sym)
            raise "invariant: method #{column_name} already defined"
          end

          aux_config.define_aux_attribute_delegate(self, column_name)
          aux_config.define_aux_attribute_delegate(self, :"#{column_name}?")
          aux_config.define_aux_attribute_delegate(self, :"#{column_name}=")
        end

        %i[save save!].each do |method_name|
          save_method = self.instance_method(method_name)
          self.define_method(method_name) do |*args, **kwargs|
            T.bind(self, ActiveRecord::Base)
            result = save_method.bind(self).call(*args, **kwargs)
            result &&=
              self
                .association(aux_config.aux_association_name)
                .target
                .send(method_name, *args, **kwargs)
            result
          end
        end

        %i[
          _read_attribute
          read_attribute
          _write_attribute
          write_attribute
        ].each do |method_name|
          read_attribute_method = self.instance_method(method_name)
          self.define_method(method_name) do |name, *args, **kwargs|
            T.bind(self, ActiveRecord::Base)
            if aux_config.is_aux_column?(name)
              target = aux_config.ensure_aux_target(self)
              T.unsafe(target).send(method_name, name, *args, **kwargs)
            else
              T
                .unsafe(read_attribute_method)
                .bind(self)
                .call(name, *args, **kwargs)
            end
          end
        end

        initialize_method = self.instance_method(:initialize)
        self.define_method(:initialize) do |args|
          T.bind(self, ActiveRecord::Base)
          aux_args, main_args =
            args.partition { |k, _| aux_config.is_aux_column?(k) }.map(&:to_h)
          initialize_method.bind(self).call(main_args)
          aux_config.assign_aux_attributes(self, aux_args)
        end

        self.define_method(:reload) do |*args|
          T.bind(self, ActiveRecord::Base)
          aux_model = aux_config.ensure_aux_target(self)
          fresh_model = self.class.find(id)
          @attributes = fresh_model.instance_variable_get(:@attributes)
          aux_model.instance_variable_set(
            :@attributes,
            fresh_model
              .association(aux_config.aux_association_name)
              .target
              .instance_variable_get(:@attributes)
          )
          self
        end

        result
      end
    end

    sig do
      params(
        aux_table_name: Symbol,
        main_columns: T::Array[String],
        aux_columns: T::Array[String]
      ).void
    end
    def check_for_overlapping_columns!(
      aux_table_name,
      main_columns,
      aux_columns
    )
      # Find overlapping columns (excluding system columns and foreign keys)
      overlapping_columns =
        aux_columns.select { |col| main_columns.include?(col) }

      if overlapping_columns.any?
        column_list = overlapping_columns.map { |col| "'#{col}'" }.join(", ")
        Kernel.raise ArgumentError,
                     "Auxiliary table '#{aux_table_name}' defines column(s) #{column_list} " \
                       "that already exist(s) in main table '#{self.table_name}'. " \
                       "Auxiliary table columns must not overlap with main table columns."
      end
    end

    # Generate auxiliary model class dynamically
    sig do
      params(
        aux_name: Symbol,
        foreign_key: KeyType,
        primary_key: KeyType
      ).returns(AuxTableConfig)
    end
    def generate_aux_config(
      aux_name,
      # The column on the aux table that points to the main table
      foreign_key: :base_table_id,
      primary_key: self.primary_key
    )
      T.bind(self, T.all(T.class_of(ActiveRecord::Base), Class))

      base_table = self.table_name
      aux_table_name = :"#{base_table}_#{aux_name}_aux"

      # Generate class name (e.g., :car_aux => "CarAux")
      aux_class_name = aux_table_name.to_s.camelize
      aux_association_name = aux_table_name.to_s.singularize.to_sym

      # Ensure the class name doesn't conflict with existing constants
      if Object.const_defined?(aux_class_name)
        Kernel.raise ArgumentError, "Class #{aux_class_name} already exists"
      end

      # Get the current class for the association
      main_class = self
      main_association_name = T.must(main_class.name&.underscore&.to_sym)

      # Create the auxiliary model class
      aux_class =
        Class.new(ActiveRecord::Base) do
          self.table_name = aux_table_name.to_s
          self.primary_key = foreign_key

          # Define the association back to the specific STI subclass
          # Foreign key points to base STI table (e.g., vehicle_id)
          # But association is to the specific subclass (e.g., Car)
          self.belongs_to(
            main_association_name,
            class_name: main_class.name,
            foreign_key:,
            primary_key:,
            inverse_of: aux_association_name
          )
        end

      # set up has_one association to the auxiliary table
      self.has_one(
        aux_association_name,
        class_name: aux_class_name,
        foreign_key:,
        primary_key:,
        inverse_of: main_association_name,
        dependent: :destroy
      )

      # so the aux table is joined against the main table
      self.default_scope { eager_load(aux_association_name) }

      # Set the constant to make the class accessible
      Object.const_set(aux_class_name, aux_class)

      AuxTableConfig.new(
        aux_table_name:,
        model_class: aux_class,
        main_class:,
        aux_association_name:,
        main_association_name:,
        foreign_key:,
        primary_key:
      )
    end
  end
  mixes_in_class_methods(ClassMethods)
end
