lib/fbe/award.rb



# frozen_string_literal: true

# SPDX-FileCopyrightText: Copyright (c) 2024-2025 Zerocracy
# SPDX-License-Identifier: MIT

require 'factbase/syntax'
require_relative 'fb'

# A generator of awards.
#
# First, you should create a bylaw, using the same Lisp-ish syntax as
# we use in queries to a Factbase, for example:
#
#  require 'fbe/award'
#  a = Fbe::Award.new('(award (in loc "lines") (give (times loc 5) "for LoC"))')
#
# Then, you can either get a bill from it:
#
#  b = a.bill(loc: 345)
#  puts b.points  # how many points to reward, a number
#  puts b.greeting  # how to explain the reward, a text
#
# Or else, you can get a bylaw text:
#
#  p = a.bylaw
#  puts p.markdown  # Markdown of the bylaw
#
# Author:: Yegor Bugayenko (yegor256@gmail.com)
# Copyright:: Copyright (c) 2024 Yegor Bugayenko
# License:: MIT
class Fbe::Award
  # Ctor.
  # @param [String] query The query with the bylaw
  # @param [String] judge The name of the judge
  def initialize(query = nil, judge: $judge, global: $global, options: $options, loog: $loog)
    query = Fbe.pmp(fb: Fbe.fb, global:, options:, loog:).hr.send(judge.tr('-', '_')) if query.nil?
    @query = query
  end

  # Build a bill object from this award query.
  # @param [Hash] vars Hash of variables
  # @return [Fbe::Award::Bill] The bill
  def bill(vars = {})
    term = Factbase::Syntax.new(@query).to_term
    term.redress!(Fbe::Award::BTerm)
    bill = Bill.new
    vars.each { |k, v| bill.set(k, v) }
    term.bill_to(bill)
    bill
  end

  # Build a bylaw object from this award query.
  # @return [Fbe::Award::Bylaw] The bylaw
  def bylaw
    term = Factbase::Syntax.new(@query).to_term
    term.redress!(Fbe::Award::PTerm)
    bylaw = Bylaw.new
    term.publish_to(bylaw)
    bylaw
  end

  # A term for bill.
  module BTerm
    def to_s
      "(#{@op} #{@operands.join(' ')})"
    end

    def static?
      true
    end

    def abstract?
      false
    end

    def bill_to(bill)
      case @op
      when :award
        @operands.each do |o|
          o.bill_to(bill)
        rescue StandardError => e
          raise "Failure in #{o}: #{e.message}"
        end
      when :aka
        @operands[0..-2].each do |o|
          o.bill_to(bill)
        rescue StandardError => e
          raise "Failure in #{o}: #{e.message}"
        end
      when :let, :set
        bill.set(@operands[0], to_val(@operands[1], bill))
      when :give
        text = @operands[1]
        text = '' if text.nil?
        bill.line(to_val(@operands[0], bill), text)
      when :explain, :in
        # nothing, just ignore
      else
        raise "Unknown term '#{@op}'"
      end
    end

    def to_val(any, bill)
      if any.is_a?(BTerm)
        any.calc(bill)
      elsif any.is_a?(Symbol)
        v = bill.vars[any]
        raise "Unknown name '#{any}' among [#{bill.vars.keys.join(', ')}]" if v.nil?
        v
      else
        any
      end
    end

    def calc(bill)
      case @op
      when :total
        bill.points
      when :if
        to_val(@operands[0], bill) ? to_val(@operands[1], bill) : to_val(@operands[2], bill)
      when :and
        @operands.all? { |o| to_val(o, bill) }
      when :or
        @operands.any? { |o| to_val(o, bill) }
      when :not
        !to_val(@operands[0], bill)
      when :eq
        to_val(@operands[0], bill) == to_val(@operands[1], bill)
      when :lt
        to_val(@operands[0], bill) < to_val(@operands[1], bill)
      when :lte
        to_val(@operands[0], bill) <= to_val(@operands[1], bill)
      when :gt
        to_val(@operands[0], bill) > to_val(@operands[1], bill)
      when :gte
        to_val(@operands[0], bill) >= to_val(@operands[1], bill)
      when :div
        to_val(@operands[0], bill) / to_val(@operands[1], bill)
      when :times
        to_val(@operands[0], bill) * to_val(@operands[1], bill)
      when :plus
        to_val(@operands[0], bill) + to_val(@operands[1], bill)
      when :minus
        to_val(@operands[0], bill) - to_val(@operands[1], bill)
      when :max
        [to_val(@operands[0], bill), to_val(@operands[1], bill)].max
      when :min
        [to_val(@operands[0], bill), to_val(@operands[1], bill)].min
      when :between
        v = to_val(@operands[0], bill)
        a = to_val(@operands[1], bill)
        b = to_val(@operands[2], bill)
        min, max = [a, b].minmax
        return 0 if (!v.negative? && v < min) || (!v.positive? && v > max)
        v.clamp(min, max)
      else
        raise "Unknown term '#{@op}'"
      end
    end
  end

  # A term for bylaw.
  module PTerm
    def to_s
      case @op
      when :total
        'total'
      when :if
        "if #{to_p(@operands[0])} then #{to_p(@operands[1])} else #{to_p(@operands[2])}"
      when :and
        @operands.map(&:to_s).join(' and ')
      when :or
        @operands.map(&:to_s).join(' or ')
      when :not
        "not #{@operands[0]}"
      when :eq
        "#{to_p(@operands[0])} = #{to_p(@operands[1])}"
      when :lt
        "#{to_p(@operands[0])} < #{to_p(@operands[1])}"
      when :lte
        "#{to_p(@operands[0])}#{to_p(@operands[1])}"
      when :gt
        "#{to_p(@operands[0])} > #{to_p(@operands[1])}"
      when :gte
        "#{to_p(@operands[0])}#{to_p(@operands[1])}"
      when :div
        "#{to_p(@operands[0])} ÷ #{to_p(@operands[1])}"
      when :times
        "#{to_p(@operands[0])} × #{to_p(@operands[1])}"
      when :plus
        "#{to_p(@operands[0])} + #{to_p(@operands[1])}"
      when :minus
        "#{to_p(@operands[0])} - #{to_p(@operands[1])}"
      when :max
        "maximum of #{to_p(@operands[0])} and #{to_p(@operands[1])}"
      when :min
        "minimum of #{to_p(@operands[0])} and #{to_p(@operands[1])}"
      when :between
        "at least #{to_p(@operands[0])} and at most #{to_p(@operands[1])}"
      else
        raise "Unknown term '#{@op}'"
      end
    end

    def static?
      true
    end

    def abstract?
      false
    end

    def publish_to(bylaw)
      case @op
      when :award
        @operands.each do |o|
          o.publish_to(bylaw)
        rescue StandardError => e
          raise "Failure in #{o}: #{e.message}"
        end
      when :aka
        @operands[0..-2].each do |o|
          o.publish_to(bylaw)
        rescue StandardError => e
          raise "Failure in #{o}: #{e.message}"
        end
        bylaw.revert(@operands.size - 1)
        bylaw.line(to_p(@operands[@operands.size - 1]))
      when :explain
        bylaw.intro(to_p(@operands[0]))
      when :in
        bylaw.line("assume that #{to_p(@operands[0])} is #{to_p(@operands[1])}")
      when :let
        bylaw.line("let #{to_p(@operands[0])} be equal to #{to_p(@operands[1])}")
        bylaw.let(@operands[0], @operands[1])
      when :set
        bylaw.line("set #{to_p(@operands[0])} to #{to_p(@operands[1])}")
      when :give
        bylaw.line("award #{to_p(@operands[0])}")
      else
        raise "Unknown term '#{@op}'"
      end
    end

    def to_p(any)
      case any
      when PTerm
        any.to_s
      when Symbol
        s = any.to_s
        subs = {
          0 => '₀',
          1 => '₁',
          2 => '₂',
          3 => '₃',
          4 => '₄',
          5 => '₅',
          6 => '₆',
          7 => '₇',
          8 => '₈',
          9 => '₉'
        }
        s.gsub!(/([a-z]+)([0-9])/) { |_| "#{Regexp.last_match[1]}#{subs[Regexp.last_match[2].to_i]}" }
        "_#{s.tr('_', '-')}_"
      when Integer, Float
        "**#{any}**"
      else
        any
      end
    end
  end

  # A bill.
  class Bill
    attr_reader :vars

    def initialize
      @lines = []
      @vars = {}
    end

    def set(var, value)
      @vars[var] = value
    end

    def line(value, text)
      return if value.zero?
      text = text.gsub(/\$\{([a-z_0-9]+)\}/) { |_x| @vars[Regexp.last_match[1].to_sym] }
      @lines << { v: value, t: text }
    end

    def points
      @lines.sum { |l| l[:v] }.to_f.round.to_i
    end

    def greeting
      items = @lines.map { |l| "#{format('%+d', l[:v])} #{l[:t]}" }
      case items.size
      when 0
        "You've earned nothing. "
      when 1
        "You've earned #{format('%+d', points)} points. "
      else
        "You've earned #{format('%+d', points)} points for this: #{items.join('; ')}. "
      end
    end
  end

  # A bylaw.
  class Bylaw
    attr_reader :vars

    def initialize
      @lines = []
      @intro = ''
      @lets = {}
    end

    def revert(num)
      @lines.slice!(-num, num)
    end

    def intro(text)
      @intro = text
    end

    def line(line)
      line = line.gsub(/\$\{([a-z_0-9]+)\}/) { |_x| "**#{@lets[Regexp.last_match[1].to_sym]}**" }
      @lines << line
    end

    def let(key, value)
      @lets[key] = value
    end

    def markdown
      pars = []
      pars << "#{@intro}." unless @intro.empty?
      pars << 'Here is how it\'s calculated:'
      if @lines.size == 1
        pars << "Just #{@lines.first}."
      else
        pars += @lines.each_with_index.map { |t, i| "#{i.zero? ? 'First' : 'Then'}, #{t}." }
      end
      pars.join(' ').gsub('. Then, award ', ', and award ').gsub(/\s{2,}/, ' ')
    end
  end
end