lib/lazylead/model.rb
# frozen_string_literal: true

# The MIT License
#
# Copyright (c) 2019-2022 Yurii Dubinka
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom
# the Software is  furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.

require "backtrace"
require "require_all"
require "forwardable"
require "active_record"
require_rel "task"
require_rel "system"
require_relative "cc"
require_relative "log"
require_relative "opts"
require_relative "postman"
require_relative "exchange"

#
# ORM domain model entities.
#
# Author:: Yurii Dubinka (yurii.dubinka@gmail.com)
# Copyright:: Copyright (c) 2019-2020 Yurii Dubinka
# License:: MIT
module Lazylead
  # ORM-related methods
  module ORM
    # Tables from database may have configuration in json column.
    # Some of those properties might be configured/overridden by environment
    # variable using macros "${...}"
    #   {
    #      ...
    #      "user" = "${my_user}",
    #      "pass" = "${my_pass}"
    #      "url" = "https://url.com"
    #      ...
    #   }
    # next, we need to configure following environment variables(ENV)
    #   my_user=XXXXX
    #   my_pass=YYYYY
    # thus, the result of this method is
    #   {
    #      ...
    #      "user" = "XXXXXX",
    #      "pass" = "YYYYYY"
    #      "url" = "https://url.com"
    #      ...
    #   }
    def env(opts)
      opts.each_with_object({}) do |e, o|
        k = e[0]
        v = e[1]
        v = ENV.fetch(v.slice(2, v.length - 3), nil) if v.respond_to?(:start_with?) && v.start_with?("${")
        o[k] = v
      end
    end

    # Convert json-based database column 'properties' to hash.
    def to_hash
      JSON.parse(properties).to_h
    end

    def to_h?
      return true unless to_hash.nil?
      false
    rescue StandardError => _e
      false
    end

    def to_s
      attributes.map { |k, v| "#{k}='#{v}'" }.join(", ")
    end

    def inspect
      to_s
    end

    # General lazylead task.
    class Task < ActiveRecord::Base
      include ORM
      belongs_to :team, foreign_key: "team_id"
      belongs_to :system, foreign_key: "system"

      # Execute task
      def exec
        sys = system.connect
        opts = props
        opts = detect_cc(sys) if opts.key? "cc"
        opts["system"] = second_sys if opts.numeric? "system"
        action.constantize.new.run(sys, postman, opts)
      end

      # Scheduling type.
      # Current implementation is based on 'rufus-scheduler' gem and supports
      #  the following types: 'cron', 'interval', 'in', 'at', 'every'
      def type
        trigger.first
      end

      # Scheduling unit.
      # Current implementation is based on 'rufus-scheduler' gem thus each
      # scheduling type has own arguments:
      #  1. Scheduling type 'cron' has 'unit' = '00 09 * * *'
      #  2. Scheduling type 'interval' has 'unit' = '2h'
      #  3. Scheduling type 'every' has 'unit' = '3h'
      #  4. Scheduling type 'in' has 'unit' = '10d'
      #  5. Scheduling type 'at' has 'unit' = '2014/12/24 2000'
      def unit
        trigger.last
      end

      def detect_cc(sys)
        opts = props
        opts["cc"] = CC.new.detect(opts["cc"], sys)
        return opts.except "cc" if opts["cc"].is_a? EmptyCC
        opts
      end

      def props
        @props ||= if team.nil?
                     Opts.new(env(to_hash))
                   else
                     Opts.new(env(team.to_hash.merge(to_hash)))
                   end
      end

      def postman
        if props.key? "postman"
          props["postman"].constantize.new
        else
          Postman.new
        end
      end

      def second_sys
        sys = System.find(props["system"])
        raise "ll-014: No ticketing system found for #{self}" if sys.nil?
        sys.connect
      end

      private

      # Parse scheduling #type and #unit
      def trigger
        @trigger ||= begin
          trg = schedule.split(":")
          raise "ll-007: illegal schedule format '#{schedule}'" unless trg.size == 2
          trg.map(&:strip).map(&:chomp)
        end
      end
    end

    # A task with extended logging
    # @see Lazylead::ORM::Task
    class Verbose
      extend Forwardable
      def_delegators :@orig, :id, :name, :team, :to_s, :inspect, :props, :type, :unit

      def initialize(orig, log = Log.new)
        @orig = orig
        @log = log
      end

      # @todo #/DEV Remove the suppression during next refactoring (or enhancements)
      #  for the method below
      #
      # rubocop:disable Metrics/AbcSize
      def exec
        Logging.mdc["tid"] = "task #{id}"
        @log.debug "'#{name}' is started."
        @log.warn "No postman, stub is used." unless props.key? "postman"
        @log.warn "No team." if team.nil?
        @orig.exec
        @log.debug "'#{name}' is completed"
      rescue StandardError => e
        msg = <<~MSG
          ll-006: Task ##{id} #{e} (#{e.class}) at #{self}
          #{Backtrace.new(e) if ARGV.include? '--trace'}
        MSG
        @log.error msg
      ensure
        Logging.mdc["tid"] = ""
      end
      # rubocop:enable Metrics/AbcSize
    end

    # A task which support retry in case of failure.
    # @see Lazylead::ORM::Task
    class Retry
      extend Forwardable
      def_delegators :@orig, :id, :name, :team, :to_s, :inspect, :props, :type, :unit

      def initialize(orig, log = Log.new)
        @orig = orig
        @log = log
      end

      def exec
        retries ||= 0
        @orig.exec
        @orig
      rescue StandardError => e
        sleep(props.fetch("attempt_wait", 0).to_f) if props.key? "attempt_wait"
        retry if (retries += 1) < props.fetch("attempt", 0).to_i
        raise e if props.key? "rethrow"
        @orig
      end
    end

    # Ticketing systems to monitor.
    class System < ActiveRecord::Base
      include ORM

      # Make an instance of ticketing system for future interaction.
      def connect(log = Log.new)
        opts = to_hash
        if opts["type"].empty?
          log.warn "No task system details provided, an empty stub is used."
          Empty.new
        else
          opts["type"].constantize.new(
            env(opts.except("type", "salt")),
            opts["salt"].blank? ? NoSalt.new : Salt.new(opts["salt"])
          )
        end
      end
    end

    # A team for lazylead task.
    # Each team may have several tasks.
    class Team < ActiveRecord::Base
      include ORM
    end
  end
end
