require "inspec/resources/command"
require "inspec/utils/database_helpers"
require "hashie/mash"
require "csv" unless defined?(CSV)
require "tempfile" unless defined?(Tempfile)

module Inspec::Resources
  # STABILITY: Experimental
  # This resource needs further testing and refinement
  #
  class SybaseSession < Inspec.resource(1)
    name "sybase_session"
    supports platform: "unix"
    # supports platform: "windows" # TODO
    desc "Use the sybase_session InSpec resource to test commands against an Sybase database"
    example <<~EXAMPLE
      sql = sybase_session(username: 'my_user', password: 'password', server: 'SYBASE', database: 'pubs2')
      describe sql.query(\"SELECT * FROM authors\").row(0).column('au_lname') do
        its('value') { should eq 'Smith' }
      end
    EXAMPLE

    # TODO: allow to set -I interfaces file
    # TODO: allow to customize -s column separator
    attr_reader :bin, :col_sep, :database, :password, :server, :sybase_home, :username

    def initialize(opts = {})
      @username = opts[:username]
      @password = opts[:password]
      @database = opts[:database]
      @server = opts[:server]
      @sybase_home = opts[:sybase_home] || "/opt/sap"
      @bin = opts[:bin] || "isql"
      @col_sep = "|"

      fail_resource "Can't run Sybase checks without authentication" unless username && password
      fail_resource "You must provide a server name for the session" unless server
      fail_resource "You must provide a database name for the session" unless database
      fail_resource "Cannot find #{bin} CLI tool" unless inspec.command(bin).exist?
    end

    def query(sql)
      # We must write the SQl to a temp file on the remote target
      # try to get a temp path
      sql_file_path = upload_sql_file(sql)

      # isql reuires that we have a matching locale set, but does not support C.UTF-8. en_US.UTF-8 is the least evil.
      command = "LANG=en_US.UTF-8 SYBASE=#{sybase_home} #{bin} -s\"#{col_sep}\" -w80000 -S #{server} -U #{username} -D #{database} -P \"#{password}\" < #{sql_file_path}"
      isql_cmd = inspec.command(command)

      # Check for isql errors
      res = isql_cmd.exit_status
      raise Inspec::Exceptions::ResourceFailed.new("isql exited with code #{res} and stderr '#{isql_cmd.stderr}', stdout '#{isql_cmd.stdout}'") unless res == 0
      # isql is ill-behaved, and returns 0 on error
      raise Inspec::Exceptions::ResourceFailed.new("isql exited with error '#{isql_cmd.stderr}', stdout '#{isql_cmd.stdout}'") unless isql_cmd.stderr == ""
      # check stdout for error messages when stderr is empty "Msg 102, Level 15, State 181:\nServer 'SYBASE', Line 1:\nIncorrect syntax near '.'.\n"
      raise Inspec::Exceptions::ResourceFailed.new("isql exited with error #{isql_cmd.stdout}") if isql_cmd.stdout.match?(/Msg\s\d+,\sLevel\s\d+,\sState\s\d+/)

      # Clean up temporary file
      rm_cmd = inspec.command("rm #{sql_file_path}")
      res = rm_cmd.exit_status # TODO: handle
      raise Inspec::Exceptions::ResourceFailed.new("Unable to delete temproary SQL input file at #{sql_file_path}: #{rm_cmd.stderr}") unless res == 0

      DatabaseHelper::SQLQueryResult.new(isql_cmd, parse_csv_result(isql_cmd.stdout))
    end

    def resource_id
      @database || "Sybase Session"
    end

    def to_s
      "Sybase Session"
    end

    private

    def parse_csv_result(stdout)
      output = stdout.gsub(/\r/, "").strip
      lines = output.lines
      # Remove second row (all dashes) and last 2 rows (blank and summary lines)
      trimmed_output = ([lines[0]] << lines.slice(2..-3)).join("")
      header_converter = Proc.new do |header|
        # This is here to suppress a warning from Hashie::Mash when it encounters a
        # header column that ends up with the name "default", which happens when using the
        # sybase_conf resource. It does mean that aly query whose output field includes the name
        # Default (exactly) will get renamed to default_value, but that seems unlikely.
        if header.match?(/^Default\s+$/)
          "default_value"
        else
          header.downcase.strip
        end
      end
      field_converter = ->(field) { field&.strip }
      CSV.parse(trimmed_output, headers: true, header_converters: header_converter, converters: field_converter, col_sep: col_sep).map { |row| Hashie::Mash.new(row.to_h) }
    end

    def upload_sql_file(sql)
      remote_temp_dir = "/tmp"
      remote_file_path = nil
      local_temp_file = Tempfile.new(["sybase", ".sql"])
      begin
        local_temp_file.write("#{sql}\n")
        local_temp_file.write("go\n")
        local_temp_file.flush
        filename = File.basename(local_temp_file.path)
        remote_file_path = "#{remote_temp_dir}/#{filename}"
        inspec.backend.upload([local_temp_file.path], remote_temp_dir)
      ensure
        local_temp_file.close
        local_temp_file.unlink
      end
      remote_file_path
    end
  end
end
