# frozen_string_literal: false
#
# Cookbook:: postgresql
# Library:: default
# Author:: David Crane (<davidc@donorschoose.org>)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

include Chef::Mixin::ShellOut

module Opscode
  module PostgresqlHelpers
    #######
    # Function to truncate value to 4 significant bits, render human readable.
    # Used in recipes/config_initdb.rb to set this attribute:
    #
    # The memory settings (shared_buffers, effective_cache_size, work_mem,
    # maintenance_work_mem and wal_buffers) will be rounded down to keep
    # the 4 most significant bits, so that SHOW will be likely to use a
    # larger divisor. The output is actually a human readable string that
    # ends with "GB", "MB" or "kB" if over 1023, exactly what Postgresql
    # will expect in a postgresql.conf setting. The output may be up to
    # 6.25% less than the original value because of the rounding.
    def binaryround(value)
      # Keep a multiplier which grows through powers of 1
      multiplier = 1

      # Truncate value to 4 most significant bits
      while value >= 16
        value = (value / 2).floor
        multiplier *= 2
      end

      # Factor any remaining powers of 2 into the multiplier
      while value == 2 * (value / 2).floor
        value = (value / 2).floor
        multiplier *= 2
      end

      # Factor enough powers of 2 back into the value to
      # leave the multiplier as a power of 1024 that can
      # be represented as units of "GB", "MB" or "kB".
      if multiplier >= 1024 * 1024 * 1024
        while multiplier > 1024 * 1024 * 1024
          value = 2 * value
          multiplier = (multiplier / 2).floor
        end
        multiplier = 1
        units = 'GB'

      elsif multiplier >= 1024 * 1024
        while multiplier > 1024 * 1024
          value = 2 * value
          multiplier = (multiplier / 2).floor
        end
        multiplier = 1
        units = 'MB'

      elsif multiplier >= 1024
        while multiplier > 1024
          value = 2 * value
          multiplier = (multiplier / 2).floor
        end
        multiplier = 1
        units = 'kB'

      else
        units = ''
      end

      # Now we can return a nice human readable string.
      "#{multiplier * value}#{units}"
    end

    #######
    # Locale Configuration

    # Function to test the date order.
    # Used in recipes/config_initdb.rb to set this attribute:
    #    node.default['postgresql']['config']['datestyle']
    def locale_date_order
      # Test locale conversion of mon=11, day=22, year=33
      testtime = DateTime.new(2033, 11, 22, 0, 0, 0, '-00:00')
      #=> #<DateTime: 2033-11-22T00:00:00-0000 ...>

      # %x - Preferred representation for the date alone, no time
      res = testtime.strftime('%x')

      return 'mdy' if res.nil?

      posM = res.index('11')
      posD = res.index('22')
      posY = res.index('33')

      if posM.nil? || posD.nil? || posY.nil?
        return 'mdy'
        elseif (posY < posM && posM < posD)
        return 'ymd'
        elseif (posD < posM)
        return 'dmy'
      end
      'mdy'
    end

    #######
    # Timezone Configuration
    require 'find'

    # Function to determine where the system stored shared timezone data.
    # Used in recipes/config_initdb.rb to detemine where it should have
    # select_default_timezone(tzdir) search.
    def pg_TZDIR
      # System time zone conversions are controlled by a timezone data file
      # identified through environment variables (TZ and TZDIR) and/or file
      # and directory naming conventions specific to the Linux distribution.
      # Each of these timezone names will have been loaded into the PostgreSQL
      # pg_timezone_names view by the package maintainer.
      #
      # Instead of using the timezone name configured as the system default,
      # the PostgreSQL server uses ones named in postgresql.conf settings
      # (timezone and log_timezone). The initdb utility does initialize those
      # settings to the timezone name that corresponds to the system default.
      #
      # The system's timezone name is actually a filename relative to the
      # shared zoneinfo directory. That is usually /usr/share/zoneinfo, but
      # it was /usr/lib/zoneinfo in older distributions and can be anywhere
      # if specified by the environment variable TZDIR. The tzset(3) manpage
      # seems to indicate the following precedence:
      tzdir = nil
      if ::File.directory?('/usr/lib/zoneinfo')
        tzdir = '/usr/lib/zoneinfo'
      else
        share_path = [ENV['TZDIR'], '/usr/share/zoneinfo'].compact.first
        tzdir = share_path if ::File.directory?(share_path)
      end
      tzdir
    end

    #######
    # Function to support select_default_timezone(tzdir), which is
    # used in recipes/config_initdb.rb.
    def validate_zone(tzname)
      # PostgreSQL does not support leap seconds, so this function tests
      # the usual Linux tzname convention to avoid a misconfiguration.
      # Assume that the tzdata package maintainer has kept all timezone
      # data files with support for leap seconds is kept under the
      # so-named "right/" subdir of the shared zoneinfo directory.
      #
      # The original PostgreSQL initdb is not Unix-specific, so it did a
      # very complicated, thorough test in its pg_tz_acceptable() function
      # that I could not begin to understand how to do in ruby :).
      #
      # Testing the tzname is good enough, since a misconfiguration
      # will result in an immediate fatal error when the PostgreSQL
      # service is started, with pgstartup.log messages such as:
      # LOG:  time zone "right/US/Eastern" appears to use leap seconds
      # DETAIL:  PostgreSQL does not support leap seconds.

      if tzname.index('right/') == 0
        false
      else
        true
      end
    end

    # Function to support select_default_timezone(tzdir), which is
    # used in recipes/config_initdb.rb.
    def scan_available_timezones(tzdir)
      # There should be an /etc/localtime zoneinfo file that is a link to
      # (or a copy of) a timezone data file under tzdir, which should have
      # been installed under the "share" directory by the tzdata package.
      #
      # The initdb utility determines which shared timezone file is being
      # used as the system's default /etc/localtime. The timezone name is
      # the timezone file path relative to the tzdir.

      bestzonename = nil

      if tzdir.nil?
        Chef::Log.error('The zoneinfo directory not found (looked for /usr/share/zoneinfo and /usr/lib/zoneinfo)')
      elsif !::File.exist?('/etc/localtime')
        Chef::Log.error('The system zoneinfo file not found (looked for /etc/localtime)')
      elsif ::File.directory?('/etc/localtime')
        Chef::Log.error('The system zoneinfo file not found (/etc/localtime is a directory instead)')
      elsif ::File.symlink?('/etc/localtime')
        # PostgreSQL initdb doesn't use the symlink target, but this
        # certainly will make sense to any system administrator. A full
        # scan of the tzdir to find the shortest filename could result
        # "US/Eastern" instead of "America/New_York" as bestzonename,
        # in spite of what the sysadmin had specified in the symlink.
        # (There are many duplicates under tzdir, with the same timezone
        # content appearing as an average of 2-3 different file names.)
        path = ::File.realdirpath('/etc/localtime')
        bestzonename = path.gsub("#{tzdir}/", '')
      else # /etc/localtime is a file, so scan for it under tzdir
        localtime_content = File.read('/etc/localtime')

        Find.find(tzdir) do |path|
          # Only consider files (skip directories or symlinks)
          next unless !::File.directory?(path) && !::File.symlink?(path)
          # Ignore any file named "posixrules" or "localtime"
          next unless ::File.basename(path) != 'posixrules' && ::File.basename(path) != 'localtime'
          # Do consider if content exactly matches /etc/localtime.
          next unless localtime_content == File.read(path)
          tzname = path.gsub("#{tzdir}/", '')
          next unless validate_zone(tzname)
          if bestzonename.nil? ||
             tzname.length < bestzonename.length ||
             (tzname.length == bestzonename.length &&
             (tzname <=> bestzonename) < 0)

            bestzonename = tzname
          end
        end
      end

      bestzonename
    end

    # Function to support select_default_timezone(tzdir), which is
    # used in recipes/config_initdb.rb.
    def identify_system_timezone(tzdir)
      resultbuf = scan_available_timezones(tzdir)

      if !resultbuf.nil?
        # Ignore Olson's rather silly "Factory" zone; use GMT instead
        resultbuf = nil if (resultbuf <=> 'Factory') == 0

      else
        # Did not find the timezone.  Fallback to use a GMT zone.  Note that the
        # Olson timezone database names the GMT-offset zones in POSIX style: plus
        # is west of Greenwich.
        testtime = DateTime.now
        std_ofs = testtime.strftime('%:z').split(':')[0].to_i

        resultbuf = [
          'Etc/GMT',
          -std_ofs > 0 ? '+' : '',
          (-std_ofs).to_s,
        ].join('')
      end

      resultbuf
    end

    #######
    # Function to determine the name of the system's default timezone.
    # Used in recipes/config_initdb.rb to set these attributes:
    #    node.default['postgresql']['config']['log_timezone']
    #    node.default['postgresql']['config']['timezone']
    def select_default_timezone(tzdir)
      system_timezone = nil

      # Check TZ environment variable
      tzname = ENV['TZ']
      if !tzname.nil? && !tzname.empty? && validate_zone(tzname)
        system_timezone = tzname

      else
        # Nope, so try to identify system timezone from /etc/localtime
        tzname = identify_system_timezone(tzdir)
        system_timezone = tzname if validate_zone(tzname)
      end

      system_timezone
    end

    #######
    # Function to execute an SQL statement in the default database.
    #   Input: Query could be a single String or an Array of String.
    #   Output: A String with |-separated columns and \n-separated rows.
    #           Note an empty output could mean psql couldn't connect.
    # This is easiest for 1-field (1-row, 1-col) results, otherwise
    # it will be complex to parse the results.
    def execute_sql(query, db_name = node['postgresql']['database_name'])
      # query could be a String or an Array of String
      statement = query.is_a?(String) ? query : query.join("\n")
      cmd = shell_out("psql -q --tuples-only --no-align -d #{db_name} -f -",
                      user: 'postgres',
                      input: statement)
      # If psql fails, generally the postgresql service is down.
      # Instead of aborting chef with a fatal error, let's just
      # pass these non-zero exitstatus back as empty cmd.stdout.
      if cmd.exitstatus == 0 && !cmd.stderr.empty?
        # An SQL failure is still a zero exitstatus, but then the
        # stderr explains the error, so let's rais that as fatal.
        Chef::Log.fatal("psql failed executing this SQL statement:\n#{statement}")
        Chef::Log.fatal(cmd.stderr)
        raise 'SQL ERROR'
      end
      cmd.stdout.chomp
    end

    # End the Opscode::PostgresqlHelpers module
  end
end
