# frozen_string_literal: true

# Generated by forge 1.0.0: mock of NovaPay Payout API for e2e and demos.
# Run: PORT=4567 WEBHOOK_URL=http://127.0.0.1:9292/webhook ruby mock_server.rb
# Auth: MOCK_API_KEY (default 'test-key') — api key / bearer token / basic password. MOCK_CALLBACK_SECRET (default 'secret').
require 'base64'
require 'json'
require 'net/http'
require 'openssl'
require 'sinatra/base'

class NovapayMock < Sinatra::Base
  FIXTURES = JSON.parse(File.read(File.join(__dir__, 'fixtures.json'))).freeze
  ID_PATH = ["id"].freeze
  STATUS_PATH = ["status"].freeze
  AMOUNT_PATH = ["amount"].freeze
  REQUIRED = ["amount", "currency", "external_id", "recipient"].freeze
  MIN_AMOUNT = 100000
  INITIAL_STATUS = "pending"
  CANCELLED_STATUS = "cancelled"
  STATUS_MAP = {"pending"=>"in_progress", "processing"=>"in_progress", "completed"=>"approved", "failed"=>"rejected", "cancelled"=>"rejected"}.freeze
  EVENTS = {"payout.completed"=>"approved", "payout.failed"=>"rejected", "payout.processing"=>"in_progress", "payout.cancelled"=>"rejected"}.freeze
  IDEMPOTENCY_HEADER = "Idempotency-Key"
  ID_PREFIX = 'novapay'

  set :port, ENV.fetch('PORT', 4567).to_i
  set :bind, '127.0.0.1'
  set :logging, false

  STATE = { payouts: {}, idempotency: {}, counter: 0 }
  LOCK = Mutex.new

  helpers do
    def json_body = @json_body ||= ((parsed = JSON.parse(request.body.tap(&:rewind).read)).is_a?(Hash) ? parsed : {} rescue {})
    def reply(status, body) = [status, { 'Content-Type' => 'application/json' }, JSON.generate(body)]
    def dig_path(hash, path) = path.reduce(hash) { |node, key| node.is_a?(Hash) ? node[key] : nil }

    def set_path(hash, path, value)
      *head, last = path
      head.reduce(hash) { |node, key| node[key] ||= {} }[last] = value
      hash
    end

    def fixture(*keys) = dig_path(FIXTURES, keys)
    def deep_copy(obj) = Marshal.load(Marshal.dump(obj))

    def authorized?
      key = ENV.fetch('MOCK_API_KEY', 'test-key')
      request.env['HTTP_X_API_KEY'] == key
    end

    def error_body(status, code)
      fixture('create_request', "response_#{status}") || fixture('create_request', "response_#{status}", 'body') ||
        { 'error' => { 'code' => code, 'message' => code.tr('_', ' ') } }
    end

    def payout_response(template, payout)
      body = deep_copy(template || {})
      set_path(body, ID_PATH, payout['id'])
      set_path(body, STATUS_PATH, payout['status'])
      body
    end

    def create_payout(body)
      LOCK.synchronize do
        STATE[:counter] += 1
        id = "#{ID_PREFIX}_#{STATE[:counter]}"
        STATE[:payouts][id] = { 'id' => id, 'status' => INITIAL_STATUS, 'request' => body }
      end
    end

    def find_payout(id) = LOCK.synchronize { STATE[:payouts][id] }

    def send_webhook(payload)
      url = ENV['WEBHOOK_URL']
      return { delivered: false, status: nil, reason: 'WEBHOOK_URL is not set' } unless url

      raw = JSON.generate(payload)
      uri = URI(url)
      req = Net::HTTP::Post.new(uri.path.empty? ? '/' : uri.request_uri, 'Content-Type' => 'application/json')
      secret = ENV.fetch('MOCK_CALLBACK_SECRET', 'secret')
      digest = OpenSSL::HMAC.digest('SHA256', secret, raw)
      req['X-NovaPay-Signature'] = digest.unpack1('H*')
      req.body = raw
      res = Net::HTTP.start(uri.host, uri.port, read_timeout: 5, open_timeout: 5) { |http| http.request(req) }
      { delivered: res.code.to_i.between?(200, 299), status: res.code.to_i }
    rescue StandardError => e
      { delivered: false, status: nil, reason: e.message }
    end

    def callback_payload(internal, payout)
      key = { 'approved' => 'callback', 'rejected' => 'callback_failed', 'in_progress' => 'callback_processing' }[internal]
      payload = deep_copy(fixture(key, 'payload') || fixture('callback', 'payload') || {})
      set_path(payload, ["payout_id"], payout['id'])
      set_path(payload, ["status"], payout['status'])
      payload
    end
  end

  before { halt(*reply(401, error_body(401, 'unauthorized'))) unless request.path_info.start_with?('/_') || authorized? }

  post '/payouts' do
    body = json_body
    missing = REQUIRED.reject { |f| body.key?(f) }
    unless missing.empty?
      halt(*reply(400, { 'error' => { 'code' => 'validation_error', 'message' => "missing required fields: #{missing.join(', ')}",
                                     'missing' => missing } }))
    end
    amount = dig_path(body, AMOUNT_PATH)
    amount = Float(amount.to_s, exception: false) unless amount.is_a?(Numeric)
    halt(*reply(422, error_body(422, 'validation_error'))) if MIN_AMOUNT && amount && amount < MIN_AMOUNT
    key = IDEMPOTENCY_HEADER && request.env["HTTP_#{IDEMPOTENCY_HEADER.upcase.tr('-', '_')}"]
    if key && (existing = LOCK.synchronize { STATE[:idempotency][key] })
      halt(*reply(409, payout_response(fixture('create_request', 'response_201'), existing)))
    end
    payout = create_payout(body)
    LOCK.synchronize { STATE[:idempotency][key] = payout } if key
    reply(201, payout_response(fixture('create_request', 'response_201'), payout))
  end

  get '/payouts/:payout_id' do
    payout = find_payout(params[:payout_id])
    halt(*reply(404, fixture('fetch_status', 'response_404') || error_body(404, 'not_found'))) unless payout
    reply(200, payout_response(fixture('fetch_status', 'response_200'), payout))
  end

  post '/payouts/:payout_id/cancel' do
    payout = find_payout(params[:payout_id])
    halt(*reply(404, error_body(404, 'not_found'))) unless payout
    halt(*reply(409, fixture('cancel', 'response_409') || error_body(409, 'invalid_status'))) unless STATUS_MAP[payout['status']] == 'in_progress'
    payout['status'] = CANCELLED_STATUS
    reply(200, payout_response(fixture('cancel', 'response_200'), payout))
  end

  get '/balance' do
    reply(200, fixture('balance', 'response_200') || {})
  end

  # Демо/e2e: перевести выплату в статус события и отправить подписанный webhook на WEBHOOK_URL.
  post '/_simulate/:id/:event' do
    payout = find_payout(params[:id])
    halt(*reply(404, error_body(404, 'not_found'))) unless payout
    internal = EVENTS[params[:event]] || STATUS_MAP[params[:event]] || params[:event]
    raw = STATUS_MAP.key(internal) || params[:event]
    payout['status'] = STATUS_MAP.select { |_r, i| i == internal }.keys.find { |r| r == params[:event] } || raw
    payload = callback_payload(internal, payout)
    payload['event'] = params[:event] if EVENTS.key?(params[:event])
    reply(200, send_webhook(payload))
  end

  get '/_state' do
    reply(200, LOCK.synchronize { STATE[:payouts].values })
  end

  run! if $PROGRAM_NAME == __FILE__
end
