require "bigdecimal"

module Stellar
  class Operation
    MAX_INT64 = 2**63 - 1
    TRUST_LINE_FLAGS_MAPPING = {
      full: Stellar::TrustLineFlags.authorized_flag,
      maintain_liabilities: Stellar::TrustLineFlags.authorized_to_maintain_liabilities_flag,
      clawback_enabled: Stellar::TrustLineFlags.trustline_clawback_enabled_flag
    }.freeze

    class << self
      include Stellar::DSL
      #
      # Construct a new Stellar::Operation from the provided
      # source account and body
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param [(Symbol, XDR::Struct)] body a tuple containing operation type and operation object
      #
      # @return [Stellar::Operation] the built operation
      def make(body:, source_account: nil)
        raise ArgumentError, "Bad :source_account" if source_account && !source_account.is_a?(Stellar::KeyPair)

        body = Stellar::Operation::Body.new(*body)

        Stellar::Operation.new(
          source_account: source_account&.muxed_account,
          body: body
        )
      end

      # Create Account operation builder
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param destination [KeyPair] the account to create
      # @param starting_balance [String, Numeric] the amount to deposit to the newly created account
      #
      # @return [Stellar::Operation] the built operation
      def create_account(destination:, starting_balance:, source_account: nil)
        op = CreateAccountOp.new(
          destination: KeyPair(destination).account_id,
          starting_balance: interpret_amount(starting_balance)
        )

        make(source_account: source_account, body: [:create_account, op])
      end

      # Account Merge operation builder
      #
      # @param [Stellar::KeyPair, nil] source_account the source account for the operation
      # @param [Stellar::KeyPair] destination the account to merge into
      #
      # @return [Stellar::Operation] the built operation
      def account_merge(destination:, source_account: nil)
        raise ArgumentError, "Bad destination" unless destination.is_a?(KeyPair)

        make(source_account: source_account, body: [:account_merge, destination.muxed_account])
      end

      # Set Options operation builder.
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param home_domain [String, nil\] the home domain of the account
      # @param signer [Signer, nil] add, remove or adjust weight of the co-signer
      # @param set [Array<AccountFlags>] flags to set
      # @param clear [Array<AccountFlags>] flags to clear
      # @param inflation_dest [KeyPair, nil] the inflation destination of the account
      #
      # @return [Stellar::Operation] the built operation
      def set_options(set: [], clear: [], home_domain: nil, signer: nil, inflation_dest: nil, source_account: nil, **attributes)
        raise ArgumentError, "Bad inflation_dest" if inflation_dest && !inflation_dest.is_a?(KeyPair)

        op = SetOptionsOp.new(
          set_flags: Stellar::AccountFlags.make_mask(set),
          clear_flags: Stellar::AccountFlags.make_mask(clear),
          master_weight: attributes[:master_weight],
          low_threshold: attributes[:low_threshold],
          med_threshold: attributes[:med_threshold],
          high_threshold: attributes[:high_threshold],
          signer: signer,
          home_domain: home_domain,
          inflation_dest: inflation_dest&.account_id
        )

        make(source_account: source_account, body: [:set_options, op])
      end

      # Bump Sequence operation builder
      #
      # @param [Stellar::KeyPair] source_account the source account for the operation
      # @param [Integer] bump_to the target sequence number for the account
      #
      # @return [Stellar::Operation] the built operation
      def bump_sequence(bump_to:, source_account: nil)
        raise ArgumentError, ":bump_to too big" unless bump_to <= MAX_INT64

        op = BumpSequenceOp.new(
          bump_to: bump_to
        )

        make(source_account: source_account, body: [:bump_sequence, op])
      end

      # Manage Data operation builder
      #
      # @param [Stellar::KeyPair, nil] source_account the source account for the operation
      # @param [String] name the name of the data entry
      # @param [String, nil] value the value of the data entry (nil to remove the entry)
      #
      # @return [Stellar::Operation] the built operation
      def manage_data(name:, value: nil, source_account: nil)
        raise ArgumentError, "Invalid :name" unless name.is_a?(String)
        raise ArgumentError, ":name too long" if name.bytesize > 64
        raise ArgumentError, ":value too long" if value && value.bytesize > 64

        op = ManageDataOp.new(
          data_name: name,
          data_value: value
        )

        make(source_account: source_account, body: [:manage_data, op])
      end

      # Change Trust operation builder
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param asset [Asset] the asset to trust
      # @param limit [String, Numeric] the maximum amount to trust, defaults to max int64 (0 deletes the trustline)
      #
      # @return [Stellar::Operation] the built operation
      def change_trust(asset: nil, limit: nil, source_account: nil, **attrs)
        if attrs.key?(:line) && !asset
          Stellar::Deprecation.warn("`line` parameter is deprecated, use `asset` instead")
          asset = attrs[:line]
        end

        op = ChangeTrustOp.new(
          line: Asset(asset).to_change_trust_asset,
          limit: limit ? interpret_amount(limit) : MAX_INT64
        )

        make(source_account: source_account, body: [:change_trust, op])
      end

      # Set Trustline Flags operation builder
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param asset [Stellar::Asset]
      # @param trustor [Stellar::KeyPair]
      # @param flags [{String, Symbol, Stellar::TrustLineFlags => true, false}] flags to to set or clear
      #
      # @return [Stellar::Operation] the built operation
      def set_trust_line_flags(asset:, trustor:, flags: {}, source_account: nil)
        op = Stellar::SetTrustLineFlagsOp.new(
          trustor: KeyPair(trustor).account_id,
          asset: Asset(asset),
          attributes: TrustLineFlags.set_clear_masks(flags)
        )

        make(source_account: source_account, body: [:set_trust_line_flags, op])
      end

      # Clawback operation builder
      #
      # @param [Stellar::KeyPair] source_account the source account for the operation
      # @param [String|Account|PublicKey|SignerKey|KeyPair] from the account to clawback from
      # @param [(Asset, Numeric)] amount the amount of asset to subtract from the balance
      #
      # @return [Stellar::Operation] the built operation
      def clawback(from:, amount:, source_account: nil)
        asset, amount = get_asset_amount(amount)

        if amount == 0
          raise ArgumentError, "Amount can not be zero"
        end

        if amount < 0
          raise ArgumentError, "Negative amount is not allowed"
        end

        op = ClawbackOp.new(
          amount: amount,
          from: KeyPair(from).muxed_account,
          asset: asset
        )

        make(source_account: source_account, body: [:clawback, op])
      end

      # Create Claimable Balance operation builder.
      #
      # @see Stellar::DSL::Claimant
      # @see https://github.com/astroband/ruby-stellar-sdk/tree/master/base/examples/claimable_balances.rb
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param asset [Asset] the asset to transfer to a claimable balance
      # @param amount [Fixnum] the amount of `asset` to put into a claimable balance
      # @param claimants [Array<Claimant>] accounts authorized to claim the balance in the future
      #
      # @return [Operation] the built operation
      def create_claimable_balance(asset:, amount:, claimants:, source_account: nil)
        op = CreateClaimableBalanceOp.new(asset: asset, amount: amount, claimants: claimants)

        make(source_account: source_account, body: [:create_claimable_balance, op])
      end

      # Helper method to create a valid CreateClaimableBalanceOp, ready to be used
      # within a transactions `operations` array.
      #
      # @see Stellar::DSL::Claimant
      # @see https://github.com/astroband/ruby-stellar-sdk/tree/master/base/examples/claimable_balances.rb
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param balance_id [ClaimableBalanceID] unique ID of claimable balance
      #
      # @return [Operation] the built operation
      def claim_claimable_balance(balance_id:, source_account: nil)
        op = ClaimClaimableBalanceOp.new(balance_id: balance_id)

        make(source_account: source_account, body: [:claim_claimable_balance, op])
      end

      # Clawback Claimable Balance operation builder
      #
      # @param [Stellar::KeyPair] source_account the source account for the operation
      # @param [String] balance_id claimable balance ID as a hexadecimal string
      #
      # @return [Stellar::Operation] the built operation
      def clawback_claimable_balance(balance_id:, source_account: nil)
        balance_id = Stellar::ClaimableBalanceID.from_xdr(balance_id, :hex)
        op = ClawbackClaimableBalanceOp.new(balance_id: balance_id)

        make(source_account: source_account, body: [:clawback_claimable_balance, op])
      rescue XDR::ReadError
        raise ArgumentError, "Claimable balance id '#{balance_id}' is invalid"
      end

      # Payment Operation builder
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param [Stellar::KeyPair] destination the receiver of the payment
      # @param [(Asset, Numeric)] amount the amount to pay
      #
      # @return [Stellar::Operation] the built operation
      def payment(destination:, amount:, source_account: nil)
        raise ArgumentError unless destination.is_a?(KeyPair)
        asset, amount = get_asset_amount(amount)

        op = PaymentOp.new(
          asset: asset,
          amount: amount,
          destination: destination.muxed_account
        )

        make(
          source_account: source_account,
          body: [:payment, op]
        )
      end

      # Path Payment Strict Receive operation builder.
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param destination [Stellar::KeyPair] the receiver of the payment
      # @param amount [Array] the destination asset and the amount to pay
      # @param with [Array] the source asset and maximum allowed source amount to pay with
      # @param path [Array<Stellar::Asset>] the payment path to use
      #
      # @return [Stellar::Operation] the built operation
      def path_payment_strict_receive(destination:, amount:, with:, path: [], source_account: nil)
        raise ArgumentError unless destination.is_a?(KeyPair)

        dest_asset, dest_amount = get_asset_amount(amount)
        send_asset, send_max = get_asset_amount(with)

        op = PathPaymentStrictReceiveOp.new(
          destination: destination.muxed_account,
          dest_asset: dest_asset,
          dest_amount: dest_amount,
          send_asset: send_asset,
          send_max: send_max,
          path: path.map { |p| Asset(p) }
        )

        make(source_account: source_account, body: [:path_payment_strict_receive, op])
      end
      alias_method :path_payment, :path_payment_strict_receive

      # Path Payment Strict Receive operation builder.
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param destination [Stellar::KeyPair] the receiver of the payment
      # @param amount [Array] the destination asset and the minimum amount of destination asset to be received
      # @param with [Array] the source asset and amount to pay with
      # @param path [Array<Stellar::Asset>] the payment path to use
      #
      # @return [Stellar::Operation] the built operation
      def path_payment_strict_send(destination:, amount:, with:, path: [], source_account: nil)
        raise ArgumentError unless destination.is_a?(KeyPair)

        dest_asset, dest_min = get_asset_amount(amount)
        send_asset, send_amount = get_asset_amount(with)

        op = PathPaymentStrictSendOp.new(
          destination: destination.muxed_account,
          send_asset: send_asset,
          send_amount: send_amount,
          dest_asset: dest_asset,
          dest_min: dest_min,
          path: path.map { |p| Asset(p) }
        )

        make(source_account: source_account, body: [:path_payment_strict_send, op])
      end

      # Manage Sell Offer operation builder
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param selling [Asset] the asset to sell
      # @param buying [Asset] the asset to buy
      # @param amount [String, Numeric] the amount of asset to sell
      # @param price [String, Numeric, Price] the price of the selling asset in terms of buying asset
      # @param offer_id [Integer] the offer ID to modify (0 to create a new offer)
      #
      # @return [Operation] the built operation
      def manage_sell_offer(selling:, buying:, amount:, price:, offer_id: 0, source_account: nil)
        selling = Asset.send(*selling) if selling.is_a?(Array)
        buying = Asset.send(*buying) if buying.is_a?(Array)

        op = ManageSellOfferOp.new(
          buying: buying,
          selling: selling,
          amount: interpret_amount(amount),
          price: Price.from(price),
          offer_id: offer_id
        )

        make(source_account: source_account, body: [:manage_sell_offer, op])
      end

      # Manage Buy Offer operation builder
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param buying [Asset] the asset to buy
      # @param selling [Asset] the asset to sell
      # @param amount [String, Numeric] the amount of asset to buy
      # @param price [String, Numeric, Price] the price of the buying asset in terms of the selling asset
      # @param offer_id [Integer] the offer ID to modify (0 to create a new offer)
      #
      # @return [Operation] the built operation
      def manage_buy_offer(buying:, selling:, amount:, price:, offer_id: 0, source_account: nil)
        buying = Asset.send(*buying) if buying.is_a?(Array)
        selling = Asset.send(*selling) if selling.is_a?(Array)

        op = ManageBuyOfferOp.new(
          buying: buying,
          selling: selling,
          buy_amount: interpret_amount(amount),
          price: Price.from(price),
          offer_id: offer_id
        )

        make(source_account: source_account, body: [:manage_buy_offer, op])
      end

      # Create Passive Sell Offer operation builder
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param selling [Asset] the asset to sell
      # @param buying [Asset] the asset to buy
      # @param amount [String, Numeric] the amount of asset to sell
      # @param price [String, Numeric, Price] the price of the selling asset in terms of buying asset
      #
      # @return [Operation] the built operation
      def create_passive_sell_offer(selling:, buying:, amount:, price:, source_account: nil)
        selling = Asset.send(*selling) if selling.is_a?(Array)
        buying = Asset.send(*buying) if buying.is_a?(Array)

        op = CreatePassiveSellOfferOp.new(
          buying: buying,
          selling: selling,
          amount: interpret_amount(amount),
          price: Price.from(price)
        )

        make(source_account: source_account, body: [:create_passive_sell_offer, op])
      end

      # Liquidity Pool Deposit operation builder
      #
      # @param [Stellar::KeyPair] source_account the source account for the operation
      # @param [String] liquidity_pool_id the liquidity pool id as hexadecimal string
      # @param [String, Numeric] max_amount_a the maximum amount of asset A to deposit
      # @param [String, Numeric] max_amount_b the maximum amount of asset B to deposit
      # @param [String, Numeric, Stellar::Price] min_price the minimum valid price of asset A in terms of asset B
      # @param [String, Numeric, Stellar::Price] max_price the maximum valid price of asset A in terms of asset B
      #
      # @return [Stellar::Operation] the built operation
      def liquidity_pool_deposit(liquidity_pool_id:, max_amount_a:, max_amount_b:, min_price:, max_price:, source_account: nil)
        op = LiquidityPoolDepositOp.new(
          liquidity_pool_id: PoolID.from_xdr(liquidity_pool_id, :hex),
          max_amount_a: interpret_amount(max_amount_a),
          max_amount_b: interpret_amount(max_amount_b),
          min_price: Price.from(min_price),
          max_price: Price.from(max_price)
        )

        make(source_account: source_account, body: [:liquidity_pool_deposit, op])
      rescue XDR::ReadError
        raise ArgumentError, "invalid liquidity pool ID '#{balance_id}'"
      end

      # Liquidity Pool Withdraw operation builder
      #
      # @param [Stellar::KeyPair] source_account the source account for the operation
      # @param [String] liquidity_pool_id the liquidity pool id as hexadecimal string
      # @param [String, Numeric] amount the number of pool shares to withdraw
      # @param [String, Numeric] min_amount_a the minimum amount of asset A to withdraw
      # @param [String, Numeric] min_amount_b the minimum amount of asset B to withdraw
      #
      # @return [Stellar::Operation] the built operation
      def liquidity_pool_withdraw(liquidity_pool_id:, amount:, min_amount_a:, min_amount_b:, source_account: nil)
        op = LiquidityPoolWithdrawOp.new(
          liquidity_pool_id: PoolID.from_xdr(liquidity_pool_id, :hex),
          amount: interpret_amount(amount),
          min_amount_a: interpret_amount(min_amount_a),
          min_amount_b: interpret_amount(min_amount_b)
        )

        make(source_account: source_account, body: [:liquidity_pool_withdraw, op])
      rescue XDR::ReadError
        raise ArgumentError, "invalid liquidity pool ID '#{balance_id}'"
      end

      # Begin Sponsoring Future Reserves operation builder
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      #
      # @return [Operation] the built operation
      def begin_sponsoring_future_reserves(sponsored:, source_account: nil)
        op = BeginSponsoringFutureReservesOp.new(
          sponsored_id: KeyPair(sponsored).account_id
        )

        make(source_account: source_account, body: [:begin_sponsoring_future_reserves, op])
      end

      # End Sponsoring Future Reserves operation builder
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      #
      # @return [Operation] the built operation
      def end_sponsoring_future_reserves(source_account: nil)
        make(source_account: source_account, body: [:end_sponsoring_future_reserves])
      end

      # Revoke Sponsorship operation builder
      #
      # @param source_account [KeyPair, nil] the source account for the operation
      # @param sponsored [#to_keypair] owner of sponsored entry
      #
      # @return [Operation] the built operation
      def revoke_sponsorship(sponsored:, source_account: nil, **attributes)
        key_fields = attributes.slice(:offer_id, :data_name, :balance_id, :liquidity_pool_id, :asset, :signer)
        raise ArgumentError, "conflicting attributes: #{key_fields.keys.join(", ")}" if key_fields.size > 1
        account_id = KeyPair(sponsored).account_id
        key, value = key_fields.first
        op = if key == :signer
          RevokeSponsorshipOp.signer(account_id: account_id, signer_key: SignerKey(value))
        else
          RevokeSponsorshipOp.ledger_key(LedgerKey.from(account_id: account_id, **key_fields))
        end
        make(source_account: source_account, body: [:revoke_sponsorship, op])
      end

      # Inflation operation builder
      #
      # @param [Stellar::KeyPair, nil] source_account the source account for the operation
      #
      # @return [Stellar::Operation] the built operation
      def inflation(source_account: nil)
        make(source_account: source_account, body: [:inflation])
      end

      private

      def get_asset_amount(values)
        amount = interpret_amount(values.last)
        asset = if values[0].is_a?(Stellar::Asset)
          values.first
        else
          Stellar::Asset.send(*values[0...-1])
        end

        [asset, amount]
      end

      def interpret_amount(amount)
        if amount.is_a?(Float)
          (amount * Stellar::ONE).floor
        else
          (BigDecimal(amount) * Stellar::ONE).floor
        end
      end
    end
  end
end
