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

module QuotePipeline
  Ok = Data.define(:value) do
    def map
      self.class.new(yield(value))
    end

    def bind
      result = yield(value)
      raise TypeError, "bind must return Ok or Err" unless result.is_a?(Ok) || result.is_a?(Err)
      result
    end
  end
  Err = Data.define(:code) do
    def map = self
    def bind = self
  end
  Request = Data.define(:sku, :quantity)
  Quote = Data.define(:sku, :quantity, :total_cents)
  module_function

  def parse(raw)
    return Err.new(:invalid_shape) unless raw.is_a?(Hash)
    sku, quantity = raw.values_at(:sku, :quantity)
    return Err.new(:invalid_sku) unless sku.is_a?(String) && sku.match?(/\A[a-z0-9-]+\z/)
    return Err.new(:invalid_quantity) unless quantity.is_a?(String) && quantity.match?(/\A[1-9][0-9]{0,2}\z/)
    Ok.new(Request.new(sku.dup.freeze, Integer(quantity, 10)))
  end

  def price(request, catalog)
    return Err.new(:unknown_sku) unless catalog.key?(request.sku)
    cents = catalog.fetch(request.sku)
    raise TypeError, "catalog price must be a nonnegative Integer" unless cents.is_a?(Integer) && cents >= 0
    Ok.new(Quote.new(request.sku, request.quantity, request.quantity * cents))
  end

  def call(raw, catalog:)
    parse(raw).bind { |request| price(request, catalog) }
  end

  def format_result(result)
    case result
    in Ok(value: Quote(total_cents:))
      "total_cents=#{total_cents}"
    in Err(code:)
      "rejected=#{code}"
    else
      raise TypeError, "not a quote result"
    end
  end
end

class QuotePipelineTest < Minitest::Test
  include QuotePipeline
  def test_valid_pipeline
    result = QuotePipeline.call({sku: "tea", quantity: "2"}, catalog: {"tea" => 125})
    assert_equal Ok.new(Quote.new("tea", 2, 250)), result
    assert_equal "total_cents=250", QuotePipeline.format_result(result)
  end

  def test_expected_failure_skips_following_stages
    called = false
    result = QuotePipeline.parse({sku: "tea", quantity: "0"}).bind { called = true; Ok.new(:bad) }
    assert_equal Err.new(:invalid_quantity), result
    refute called
    assert_equal "rejected=invalid_quantity", QuotePipeline.format_result(result)
  end

  def test_shape_sku_and_unknown_product
    assert_equal Err.new(:invalid_shape), QuotePipeline.parse(nil)
    assert_equal Err.new(:invalid_sku), QuotePipeline.parse({sku: nil, quantity: "2"})
    assert_equal Err.new(:unknown_sku), QuotePipeline.call({sku: "tea", quantity: "2"}, catalog: {})
  end

  def test_quantity_policy
    ["", "0", "-1", "+2", "02", " 2", "2x", "1000", 2, nil].each do |quantity|
      assert_equal Err.new(:invalid_quantity), QuotePipeline.parse({sku: "tea", quantity: quantity})
    end
    assert_equal 999, QuotePipeline.parse({sku: "tea", quantity: "999"}).value.quantity
  end

  def test_map_does_not_flatten_but_bind_does
    assert_equal Ok.new(Ok.new(2)), Ok.new(1).map { |n| Ok.new(n + 1) }
    assert_equal Ok.new(2), Ok.new(1).bind { |n| Ok.new(n + 1) }
    assert_raises(TypeError) { Ok.new(1).bind { 2 } }
  end

  def test_false_and_nil_are_successful_values
    assert_equal Ok.new(false), Ok.new(1).map { false }
    assert_equal Ok.new(nil), Ok.new(1).map { nil }
    assert_equal Ok.new(:continued), Ok.new(false).bind { Ok.new(:continued) }
  end

  def test_programming_errors_are_not_hidden_as_validation_errors
    assert_raises(TypeError) do
      QuotePipeline.call({sku: "tea", quantity: "2"}, catalog: {"tea" => nil})
    end
  end

  def test_input_is_not_modified_and_sku_is_copied
    sku = +"tea"
    input = {sku: sku, quantity: "1"}
    result = QuotePipeline.call(input, catalog: {"tea" => 0})
    sku << "!"
    assert_equal "tea", result.value.sku
    assert_equal 0, result.value.total_cents
    assert result.value.sku.frozen?
  end

  def test_result_wrapper_does_not_deep_freeze_arbitrary_payload
    values = []
    result = Ok.new(values)
    values << 1
    assert_equal [1], result.value
    assert result.frozen?
  end
end
