# frozen_string_literal: true

require 'fileutils'
require 'htmlentities'
require 'nokogiri'

module PWN
  module Plugins
    # This plugin is used for interacting w/ baresip over a screen session.
    module BareSIP
      @session_data = []
      # Supported Method Parameters::
      # baresip_http_call(
      #   http_method: 'optional HTTP method (defaults to GET)
      #   cmd: 'required rest call to make per the schema',
      # )

      private_class_method def self.baresip_http_call(opts = {})
        baresip_obj = opts[:baresip_obj]
        cmd = opts[:cmd]
        http_listen_ip_port = opts[:http_listen_ip_port]
        baresip_url = "http://#{http_listen_ip_port}"

        max_conn_attempts = 30
        conn_attempt = 0

        begin
          conn_attempt += 1

          browser_obj = PWN::Plugins::TransparentBrowser.open(
            browser_type: :rest
          )
          rest_client = browser_obj[:browser]::Request

          response = rest_client.execute(
            method: :get,
            url: "#{baresip_url}/?#{cmd}",
            verify_ssl: false
          )

          Nokogiri::HTML.parse(response)
        rescue Errno::ECONNREFUSED => e
          raise e if conn_attempt > max_conn_attempts

          sleep 1
          retry
        end
      rescue StandardError => e
        case e.message
        when '400 Bad Request', '404 Resource Not Found'
          "#{e.message}: #{e.response}"
        else
          raise e
        end
      end

      # Supported Method Parameters::
      # baresip_obj = PWN::Plugins::BareSIP.start(
      #   src_num: 'Optional source phone number displayed',
      #   baresip_bin: 'Optional path of baresip binary (Defaults to /usr/bin/baresip)',
      #   config_root: 'Optional dir of baresip config (Defaults to ~/.baresip)',
      #   session_root: 'Optional dir of baresip session (Defaults to Dir.pwd)',
      #   screenlog_path: 'Optional path of screenlog file (Defaults to screenlog.txt)',
      #   screen_session: 'Optional name of screen session (Defaults baresip)'
      # )

      public_class_method def self.start(opts = {})
        src_num = opts[:src_num]

        baresip_bin = opts[:baresip_bin] if File.exist?(
          opts[:baresip_bin].to_s
        )
        baresip_bin ||= '/usr/bin/baresip'

        session_root = opts[:session_root] if Dir.exist?(opts[:session_root].to_s)

        config_root = opts[:config_root] if Dir.exist?(
          opts[:config_root].to_s
        )
        config_root ||= "#{Dir.home}/.baresip"

        config = "#{config_root}/config"
        config_lines = File.readlines(config)
        http_list_entry = config_lines.grep(/^http_listen\s.+$/)

        raise "no http_listen value found in #{config}." if http_list_entry.empty?

        # Update http_listen value in respective config with random available port
        # random_port = -1
        # port_in_use = true
        # while port_in_use
        #   random_port = Random.rand(1024..65_535)
        #   port_in_use = PWN::Plugins::Sock.check_port_in_use(port: random_port)
        # end
        random_port = PWN::Plugins::Sock.get_random_unused_port
        http_listen_ip_port = "127.0.0.1:#{random_port}"

        updated_config_content = ''
        File.read(config).each_line do |line|
          this_config_line = line
          # Only allow one call per thread.
          this_config_line = "call_max_calls 1\n" if line.match?(/^call_max_calls\s.+$/)

          # One random HTTP listener / thread
          if line.match?(/^http_listen\s.+$/)
            this_config_line = "http_listen #{http_listen_ip_port}\n"
          end
          updated_config_content = "#{updated_config_content}#{this_config_line}"
        end
        File.write(config, updated_config_content)

        baresip_obj = {}

        screenlog_path = opts[:screenlog_path]
        screenlog_path ||= "#{session_root}/screenlog.txt"
        baresip_obj[:screenlog_path] = screenlog_path

        screen_session = opts[:screen_session]
        screen_session ||= 'baresip'

        baresip_obj[:config_root] = config_root
        baresip_obj[:http_listen_ip_port] = http_listen_ip_port
        baresip_obj[:session_root] = session_root
        baresip_obj[:screen_session] = screen_session

        # Prefer running baresip in detached screen vs --daemon mode
        # Since sndfile doesn't produce .wav files in --daemon mode
        system(
          'screen',
          '-T',
          'xterm',
          '-L',
          '-Logfile',
          screenlog_path,
          '-S',
          screen_session,
          '-d',
          '-m',
          baresip_bin,
          '-f',
          config_root,
          '-e',
          '/insmod httpd',
          '-e',
          '/insmod sndfile',
          '-v'
        )

        ok = 'registered successfully'
        gone = 'account: No SIP accounts found'
        forb = '403 Forbidden'

        # TODO: Make this faster.
        print 'Starting baresip...'
        loop do
          next unless File.exist?(screenlog_path)

          dump_session_data = File.readlines(screenlog_path)
          dump_session_data.delete_if do |line|
            line.include?('ua: using best effort AF: af=AF_INET')
          end
          break if dump_session_data.select { |s| s.include?(ok) }.length.positive?

          next unless dump_session_data.select { |s| s.include?(gone) }.length.positive?
          next unless dump_session_data.select { |s| s.include?(forb) }.length.positive?

          error = gone if dump_session_data.select { |s| s.include?(gone) }.length.positive?
          error = forbid if dump_session_data.select { |s| s.include?(forb) }.length.positive?
          raise "Something happened when attempting to start baresip: #{error}"
        end
        puts 'ready.'

        baresip_obj
      rescue StandardError => e
        raise e
      end

      # Supported Method Parameters::
      # cmd_resp = PWN::Plugins::BareSIP.baresip_exec(
      #   baresip_obj: 'Required - baresip obj returned from #start method',
      #   cmd: 'Required - command to send to baresip HTTP daemon'
      # )

      public_class_method def self.baresip_exec(opts = {})
        baresip_obj = opts[:baresip_obj]
        http_listen_ip_port = baresip_obj[:http_listen_ip_port]
        cmd = opts[:cmd]

        baresip_http_call(
          baresip_obj: baresip_obj,
          http_listen_ip_port: http_listen_ip_port,
          cmd: cmd
        )
      rescue StandardError => e
        raise e
      end

      # Supported Method Parameters::
      # PWN::Plugins::BareSIP.stop(
      #   screen_session: 'Required - screen session to stop'
      # )

      public_class_method def self.stop(opts = {})
        baresip_obj = opts[:baresip_obj]
        screen_session = baresip_obj[:screen_session]

        puts "STOPPING #{baresip_obj[:screen_session]}"
        cmd_resp = baresip_exec(
          baresip_obj: baresip_obj,
          cmd: "/quit\r\n"
        )
      rescue StandardError => e
        raise e
      end

      # Supported Method Parameters::
      # PWN::Plugins::BareSIP.parse_target_file(
      #   target_file: 'Required - txt file containing phone numbers to dial',
      #   randomize: 'Optional - randomize list of phone numbers to dial (Defaults to false)'
      # )

      public_class_method def self.parse_target_file(opts = {})
        target_file = opts[:target_file]
        randomize = opts[:randomize]

        # Parse entries from target_file and build out our target range
        target_lines = File.readlines(target_file)
        target_range = []
        print 'Initializing targets...'
        target_lines.each do |target_line|
          next if target_line.match?(/^#.*$/)

          target_line.scrub.strip.chomp.delete('(').delete(')').delete('.').delete('+')
          if target_line.include?('-')
            split_range = target_line.split('-')
            if split_range.length == 2
              from_num = split_range.first.to_i
              to_num = split_range.last.to_i

              (from_num..to_num).each do |number_in_range|
                target_range.push(number_in_range)
              end
            else
              target_line.scrub.strip.chomp.delete('-')
            end
          else
            target_range.push(target_line.to_i)
          end
        end
        puts 'complete.'

        # Randomize targets if applicable
        target_range.shuffle! if randomize

        target_range
      rescue StandardError => e
        raise e
      end

      # Supported Method Parameters::
      # PWN::Plugins::BareSIP.apply_src_num_rules(
      #   target_num: 'Required - destination number to derive source number',
      #   src_num_rules: 'Optional - Comma-delimited list of rules for src_num format (i.e. XXXXXXXXXX, self, same_country, same_area, and/or same_prefix [Defaults to random src_num w/ same length as target_num])'
      # )

      public_class_method def self.apply_src_num_rules(opts = {})
        config_root = opts[:config_root] if Dir.exist?(
          opts[:config_root].to_s
        )
        config_root ||= "#{Dir.home}/.baresip"
        src_num_rules = opts[:src_num_rules]
        target_num = opts[:target_num]

        src_num_rules_arr = []
        if src_num_rules
          src_num_rules_arr = src_num_rules.delete("\s").split(',').map(
            &:to_sym
          )
        end

        return 'static' if src_num_rules_arr.include?(:static)

        case target_num.to_s.length
        when 10
          # area+prefix+suffix
          country = ''
        when 11
          # 1 digit country+area+prefix+suffix
          country = format('%0.1d', Random.rand(1..9))
          country = target_num.to_s.chars.first if src_num_rules_arr.include?(
            :same_country
          )
        when 12
          # 2 digit country+area+prefix+suffix
          country = format('%0.2d', Random.rand(1..99))
          country = target_num.to_s.chars[0..1].join if src_num_rules_arr.include?(
            :same_country
          )
        when 13
          # 3 digit country+area+prefix+suffix
          country = format('%0.3d', Random.rand(1..999))
          country = target_num.to_s.chars[0..2].join if src_num_rules_arr.include?(
            :same_country
          )
        when 14
          # 4 digit country+area+prefix+suffix
          country = format('%0.4d', Random.rand(1..9999))
          country = target_num.to_s.chars[0..3].join if src_num_rules_arr.include?(
            :same_country
          )
        else
          raise "Target # should be 10-14 digits. Length is: #{target_num.to_s.length}"
        end
        src_num_rules_arr.delete(:same_country)

        # > 799 for prefix leads to call issues when calling 800 numbers.
        # area = format('%0.3s', Random.rand(200..999))
        area = format('%0.3d', Random.rand(200..999))
        area = target_num.to_s.chars[-10..-8].join if src_num_rules_arr.include?(
          :same_area
        )
        src_num_rules_arr.delete(:same_area)

        prefix = format('%0.3d', Random.rand(200..999))
        prefix = target_num.to_s.chars[-7..-5].join if src_num_rules_arr.include?(
          :same_prefix
        )
        src_num_rules_arr.delete(:same_prefix)

        suffix = format('%0.4d', Random.rand(0..9999))

        # Defaults to Random Source Number
        src_num = "#{country}#{area}#{prefix}#{suffix}"

        # Change to same as dest if :self is passed
        src_num = target_num if src_num_rules_arr.include?(:self)
        src_num_rules_arr.delete(:self)

        # Assume a custom number is passed if an element
        # still exists in src_num_rules_arr
        # Cast symbol to string, string to integer to massage input,
        # and cast back to string.
        src_num = src_num_rules_arr.first.to_s.to_i.to_s if src_num_rules_arr.any?

        # TODO: Update ~/.baresip/accounts to apply source number
        sip_accounts_path = "#{config_root}/accounts"
        updated_account_content = ''
        File.read(sip_accounts_path).each_line do |line|
          this_account_line = line
          if line.match?(/^<sip:.+@.+>/)
            sip_account_to_keep = this_account_line.split('@').last
            this_account_line = "<sip:#{src_num}@#{sip_account_to_keep}"
          end
          updated_account_content = "#{updated_account_content}#{this_account_line}"
        end
        File.write(sip_accounts_path, updated_account_content)

        src_num
      rescue StandardError => e
        raise e
      end

      # Supported Method Parameters::
      # PWN::Plugins::BareSIP.dial_target_in_list(
      # )

      public_class_method def self.dial_target_in_list(opts = {})
        baresip_bin = opts[:baresip_bin]
        target_num = opts[:target_num]

        config_root = opts[:config_root] if Dir.exist?(
          opts[:config_root].to_s
        )
        config_root ||= "#{Dir.home}/.baresip"

        session_root = opts[:session_root]
        session_root ||= Dir.pwd

        randomize = opts[:randomize]
        src_num_rules = opts[:src_num_rules]

        seconds_to_record = opts[:seconds_to_record].to_i
        seconds_to_record = 60 if seconds_to_record.zero?

        sox_bin = opts[:sox_bin] if File.exist?(opts[:sox_bin].to_s)
        sox_bin ||= '/usr/bin/sox'

        speech_to_text = true if opts[:speech_to_text]
        speech_to_text ||= false

        waveform_bin = 'waveform'

        # Intialize empty baresip obj for ensure block below
        baresip_obj = {}

        # Colors!
        red = "\e[31m"
        green = "\e[32m"
        yellow = "\e[33m"
        cayan = "\e[36m"
        end_of_color = "\e[0m"

        config_root_for_target_num = "#{config_root}-#{target_num}"
        FileUtils.cp_r(config_root, config_root_for_target_num)
        src_num = apply_src_num_rules(
          config_root: config_root_for_target_num,
          target_num: target_num,
          src_num_rules: src_num_rules
        )

        call_resp_hash = {}

        call_started = Time.now.strftime('%Y-%m-%d_%H.%M.%S')

        call_resp_hash[:call_started] = call_started
        call_resp_hash[:src_num] = src_num
        call_resp_hash[:src_num_rules] = src_num_rules
        call_resp_hash[:target_num] = target_num
        target_num_root = "#{session_root}/#{target_num}-#{call_started}"
        Dir.mkdir(target_num_root)

        screenlog_path = "#{target_num_root}/screenlog-#{target_num}.txt"
        screen_session = "#{File.basename($PROGRAM_NAME)}-#{target_num}"

        # Start baresip in detached screen to support commands over HTTP
        # and call recording to wav files
        baresip_obj = start(
          src_num: src_num,
          baresip_bin: baresip_bin,
          config_root: config_root_for_target_num,
          session_root: session_root,
          screenlog_path: screenlog_path,
          screen_session: screen_session
        )

        # session_root = baresip_obj[:session_root]
        config_root = baresip_obj[:config_root]
        config = "#{config_root}/config"

        puts "#{green}#{call_started} >>>#{end_of_color}"
        puts "#{yellow}dialing #{target_num}#{end_of_color}"

        cmd_resp = baresip_exec(
          baresip_obj: baresip_obj,
          cmd: "/dial #{target_num}\r\n"
        )
        puts "/dial #{target_num} RESP:"
        puts cmd_resp.xpath('//pre').text

        cmd_resp = baresip_exec(
          baresip_obj: baresip_obj,
          cmd: "/listcalls\r\n"
        )
        puts '/listcalls RESP:'
        puts cmd_resp.xpath('//pre').text

        puts red
        # Hangup if session is closed.
        session_closed = ': session closed'

        reason = 'recording limit reached'
        seconds_recorded = 0
        seconds_to_record.downto(1).each do |countdown|
          seconds_recorded += 1
          print "#{seconds_to_record}s to record - remaining: #{format('%-9.9s', countdown)}"
          print "\r"

          dump_session_data = File.readlines(screenlog_path)
          dump_session_data.delete_if do |line|
            line.include?('ua: using best effort AF: af=AF_INET')
          end

          if dump_session_data.select { |s| s.downcase.include?(session_closed) }.length.positive?
            reason = dump_session_data.find { |s| s.downcase.include?(session_closed) }
            break
          end

          sleep 1
        end
        call_resp_hash[:seconds_recorded] = seconds_recorded
        puts end_of_color

        # Move to ensure block?
        # call_stopped = Time.now.strftime('%Y-%m-%d_%H.%M.%S')
        # puts "\n#{green}#{call_stopped} >>> #{reason} #{target_num}#{end_of_color}"
        # call_resp_hash[:call_stopped] = call_stopped
        # call_resp_hash[:reason] = reason
        # puts "call termination reason: #{reason}"

        # stop(baresip_obj: baresip_obj)
        # FileUtils.rm_rf(config_root_for_target_num)
        # End of ensure block

        absolute_recording = ''
        relative_recording = ''
        Dir.glob("#{session_root}/dump-*=>*#{target_num}*.wav").each do |path|
          wav = File.basename(path)
          File.delete(path) if wav.match(/^dump-.+#{target_num}.+-enc\.wav$/)
          next unless wav.match(/^dump-.+#{target_num}.+-dec.wav/)

          FileUtils.mv(path, target_num_root)
          absolute_recording = "#{target_num_root}/#{wav}"
          relative_recording = "#{target_num}-#{call_started}/#{wav}"
        end

        screenlog_file = File.basename(screenlog_path)
        relative_screenlog = "#{target_num}-#{call_started}/#{screenlog_file}"

        call_resp_hash[:screenlog] = relative_screenlog
        call_resp_hash[:recording] = '--'
        call_resp_hash[:waveform] = '--'
        call_resp_hash[:spectrogram] = '--'

        unless absolute_recording.empty?
          puts cayan

          call_resp_hash[:recording] = relative_recording

          absolute_spectrogram = "#{absolute_recording}-spectrogram.png"
          relative_spectrogram = "#{relative_recording}-spectrogram.png"
          print "Generating Audio Spectrogram for #{absolute_recording}..."
          system(
            sox_bin,
            '--show-progress',
            '--type',
            'sndfile',
            '--encoding',
            'signed-integer',
            '--bits',
            '16',
            '--endian',
            'little',
            '--channels',
            '1',
            '--rate',
            '8000',
            absolute_recording,
            '-n',
            'spectrogram',
            '-o',
            absolute_spectrogram,
            '-d',
            seconds_to_record.to_s
          )
          puts 'complete.'
          call_resp_hash[:spectrogram] = relative_spectrogram

          absolute_waveform = "#{absolute_recording}-waveform.png"
          relative_waveform = "#{relative_recording}-waveform.png"
          print "Generating Audio Waveform for #{absolute_recording}..."
          system(
            waveform_bin,
            '--method',
            'peak',
            '--color',
            '#FF0000',
            '--background',
            '#000000',
            '--force',
            absolute_recording,
            absolute_waveform
          )
          puts 'complete.'
          call_resp_hash[:waveform] = relative_waveform

          call_resp_hash[:speech_to_text] = 'N/A'
          if speech_to_text
            abs_rec_dir = File.dirname(absolute_recording)
            abs_rec_file = File.basename(absolute_recording, '.*')
            absolute_speech_to_text = "#{abs_rec_dir}/#{abs_rec_file}.txt"
            # absolute_speech_to_text = "#{absolute_recording}.txt"

            rel_rec_dir = File.dirname(relative_recording)
            rel_rec_file = File.basename(relative_recording, '.*')
            relative_speech_to_text = "#{rel_rec_dir}/#{rel_rec_file}.txt"
            # relative_speech_to_text = "#{relative_recording}.txt"
            PWN::Plugins::Voice.speech_to_text(
              audio_file_path: absolute_recording,
              output_dir: target_num_root
            )
            print "Generating Speech-to-Text for #{absolute_recording}..."
            puts 'complete.'
            call_resp_hash[:speech_to_text] = relative_speech_to_text
          end
          puts end_of_color
        end

        call_resp_hash
      rescue StandardError => e
        raise e
      ensure
        # Ensure baresip session is stopped
        call_stopped = Time.now.strftime('%Y-%m-%d_%H.%M.%S')
        puts "\n#{green}#{call_stopped} >>> #{reason} #{target_num}#{end_of_color}"
        call_resp_hash[:call_stopped] = call_stopped
        call_resp_hash[:reason] = reason
        puts "call termination reason: #{reason}"

        stop(baresip_obj: baresip_obj)
        FileUtils.rm_rf(config_root_for_target_num)
      end

      # Supported Method Parameters::
      # PWN::Plugins::BareSIP.recon(
      # )

      public_class_method def self.recon(opts = {})
        baresip_bin = opts[:baresip_bin]
        config_root = opts[:config_root] if Dir.exist?(
          opts[:config_root].to_s
        )
        config_root ||= "#{Dir.home}/.baresip"
        session_root = opts[:session_root]
        session_root ||= Dir.pwd
        target_file = opts[:target_file]
        randomize = opts[:randomize]
        src_num_rules = opts[:src_num_rules]
        max_threads = opts[:max_threads].to_i
        max_threads = 3 if max_threads.zero?
        seconds_to_record = opts[:seconds_to_record].to_i
        seconds_to_record = 60 if seconds_to_record.zero?
        sox_bin = opts[:sox_bin] if File.exist?(opts[:sox_bin].to_s)
        sox_bin ||= '/usr/bin/sox'
        speech_to_text = opts[:speech_to_text]
        report_name = opts[:report_name]

        target_range = parse_target_file(
          target_file: target_file,
          randomize: randomize
        )

        results_hash = {
          report_name: HTMLEntities.new.encode(
            report_name.to_s.scrub.strip.chomp
          ),
          session_started: Time.now.strftime('%Y-%m-%d_%H.%M.%S'),
          data: []
        }

        # Change to session_root _before_ executing threads
        Dir.chdir(session_root)

        # Multi-thread calls!
        mutex = Mutex.new
        PWN::Plugins::ThreadPool.fill(
          enumerable_array: target_range,
          max_threads: max_threads
        ) do |target_num|
          call_resp_hash = dial_target_in_list(
            baresip_bin: baresip_bin,
            target_num: target_num,
            config_root: config_root,
            session_root: session_root,
            randomize: randomize,
            src_num_rules: src_num_rules,
            seconds_to_record: seconds_to_record,
            sox_bin: sox_bin,
            speech_to_text: speech_to_text
          )

          # Push Call Results to results_hash[:data]
          mutex.synchronize do
            results_hash[:data].push(call_resp_hash)
          end
        end
        results_hash[:session_ended] = Time.now.strftime('%Y-%m-%d_%H.%M.%S')

        results_hash
      rescue StandardError => e
        raise e
      end

      # Author(s):: 0day Inc. <support@0dayinc.com>

      public_class_method def self.authors
        "AUTHOR(S):
          0day Inc. <support@0dayinc.com>
        "
      end

      # Display Usage for this Module

      public_class_method def self.help
        puts "USAGE:
          baresip_obj = #{self}.start(
            src_num: 'Optional source phone number displayed',
            baresip_bin: 'Optional path of baresip binary (Defaults to /usr/bin/baresip)',
            config_root: 'Optional dir of baresip config (Defaults to ~/.baresip)',
            session_root: 'Optional dir of baresip session (Defaults to Dir.pwd)',
            screenlog_path: 'Optional path of screenlog file (Defaults to screenlog.txt)',
            screen_session: 'Optional name of screen session (Defaults baresip)'
          )

          cmd_resp = #{self}.baresip_exec(
            baresip_obj: 'Required - baresip obj returned from #start method',
            cmd: 'Required - command to send to baresip HTTP daemon'
          )

          stopped_bool = #{self}.stop(
            screen_session: 'Required - screen session to stop'
          )

          #{self}.authors
        "
      end
    end
  end
end
