# frozen_string_literal: true
gem "minitest", "6.0.6"
require "minitest/autorun"

class TypedSettings
  Field = Data.define(:type, :default, :validator)
  class << self
    def fields
      parent = superclass.respond_to?(:fields) ? superclass.fields : {}
      parent.merge(@fields || {}).freeze
    end

    def field(name, type:, default:, &validator)
      raise ArgumentError, "invalid field name" unless name.is_a?(Symbol) && name.to_s.match?(/\A[a-z][a-z0-9_]*\z/)
      raise ArgumentError, "field shadows a method" if method_defined?(name) || private_method_defined?(name) || method_defined?("#{name}=")
      raise ArgumentError, "unsupported type" unless [String, Integer].include?(type)
      spec = Field.new(type, copy_value(default), validator)
      validate_value!(spec, default)
      @fields = (@fields || {}).merge(name => spec).freeze

      define_method(name) { @values.fetch(name) }
      define_method("#{name}=") { |value| write_field(name, value) }
    end

    def copy_value(value)
      value.is_a?(String) ? value.dup.freeze : value
    end

    def validate_value!(spec, value)
      raise ArgumentError, "wrong field type" unless value.is_a?(spec.type)
      raise ArgumentError, "field validation failed" if spec.validator && !spec.validator.call(value)
    end
  end

  def initialize(**values)
    unknown = values.keys - self.class.fields.keys
    raise ArgumentError, "unknown fields: #{unknown.join(', ')}" unless unknown.empty?
    @values = {}
    self.class.fields.each { |name, spec| write_field(name, values.fetch(name, spec.default)) }
  end

  private

  def write_field(name, value)
    spec = self.class.fields.fetch(name)
    self.class.validate_value!(spec, value)
    @values[name] = self.class.copy_value(value)
  end
end

class HttpSettings < TypedSettings
  field :host, type: String, default: "localhost" do |value|
    !value.empty?
  end
  field :timeout, type: Integer, default: 5 do |value|
    value.between?(1, 60)
  end
end

class TypedSettingsTest < Minitest::Test
  def test_generated_methods_have_normal_reflection
    settings = HttpSettings.new(timeout: 10)
    assert_equal "localhost", settings.host
    assert_equal 10, settings.timeout
    assert settings.respond_to?(:timeout)
    assert settings.respond_to?(:timeout=)
    assert_equal 0, settings.method(:timeout).arity
    refute settings.respond_to?(:missing)
  end

  def test_writer_validation_keeps_previous_value
    settings = HttpSettings.new
    assert_raises(ArgumentError) { settings.timeout = "10" }
    assert_raises(ArgumentError) { settings.timeout = 0 }
    assert_raises(ArgumentError) { settings.host = "" }
    assert_equal 5, settings.timeout
    assert_equal "localhost", settings.host
  end

  def test_unknown_inputs_and_methods_fail
    assert_raises(ArgumentError) { HttpSettings.new(typo: 1) }
    assert_raises(NoMethodError) { HttpSettings.new.typo }
  end

  def test_no_shared_mutable_string_between_caller_and_instances
    value = +"example.test"
    first = HttpSettings.new(host: value)
    second = HttpSettings.new
    value << "!"
    assert_equal "example.test", first.host
    assert_equal "localhost", second.host
    assert_raises(FrozenError) { first.host << "!" }
  end

  def test_subclass_fields_do_not_modify_parent
    child = Class.new(HttpSettings)
    child.field :retries, type: Integer, default: 2
    assert_equal 2, child.new.retries
    assert child.new.respond_to?(:timeout)
    refute HttpSettings.fields.key?(:retries)
    refute HttpSettings.new.respond_to?(:retries)
  end

  def test_invalid_schema_is_rejected_before_method_creation
    [:send, :initialize, :timeout, :"bad-name"].each do |name|
      assert_raises(ArgumentError) { HttpSettings.field(name, type: Integer, default: 1) }
    end
    assert_raises(ArgumentError) { Class.new(TypedSettings).field(:x, type: Array, default: []) }
    klass = Class.new(TypedSettings)
    assert_raises(ArgumentError) { klass.field(:age, type: Integer, default: "bad") }
    refute klass.method_defined?(:age)
    assert_empty klass.fields
  end
end
