# encoding: utf-8

# Copyright (c) [2019] SUSE LLC
#
# All Rights Reserved.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of version 2 of the GNU General Public License as published
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, contact SUSE LLC.
#
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.

require "pathname"
require "y2storage/storage_manager"
require "y2storage/blk_device"

module Y2Storage
  # Utility class to find devices in a devicegraph
  class DeviceFinder
    include Yast::Logger

    # Constructor
    #
    # @param devicegraph [Devicegraph] see {#devicegraph}
    def initialize(devicegraph)
      @devicegraph = devicegraph
    end

    # Find device with given name e.g. /dev/sda3
    #
    # In case of LUKSes and MDs, the device might be found by using an alternative name,
    # see {#alternative_names}.
    #
    # @param name [String]
    # @param with_alternatives [Boolean] whether to try the search with possible alternative names
    # @return [Device, nil] device matching the name, nil if no device was found
    def find_by_name(name, with_alternatives)
      names = all_names(name, with_alternatives)

      device = call_finder(:name, names)
      return device if device

      log.info "Device #{name} not found by name"
      nil
    end

    # Finds a device by any name including any symbolic link in the /dev directory
    #
    # This is different from {BlkDevice.find_by_any_name} in several ways:
    #
    # * It will find any matching device, not only block devices (e.g. LVM VGs
    #   also have a name but are not block devices).
    # * It can be called on any devicegraph, not only probed.
    # * It uses a system lookup only when necessary (i.e. all the cheaper
    #   methods for finding the device have been unsuccessful).
    # * It avoids system lookup in potentially risky scenarios (like an outdated
    #   {StorageManager#probed}).
    #
    # In case of LUKSes and MDs, the device might be found by using an alternative name,
    # see {#alternative_names}.
    #
    # @param name [String] can be a kernel name like "/dev/sda1" or any symbolic
    #   link below the /dev directory
    # @param with_alternatives [Boolean] whether to try the search with possible alternative names
    # @return [Device, nil] device matching the name, nil if no device was found
    def find_by_any_name(name, with_alternatives)
      names = all_names(name, with_alternatives)

      device = call_finder(:all_names, names)
      return device if device

      # If no device yet, there is still a chance using the slower
      # BlkDevice.find_by_any_name. Unfortunatelly this only works in the
      # probed devicegraph by design. Moreover it can only be safely called
      # under certain circumstances.
      if !udev_lookup_possible?
        log.info "System lookup cannot be used to find #{name}"
        return nil
      end

      device = call_finder(:system_lookup, names)
      return device if device

      log.info "Device #{name} not found via system lookup"
      nil
    end

  private

    # Devicegraph in which the searchs must be performed
    # @return [Devicegraph]
    attr_reader :devicegraph

    # Auxiliary method to calculate all the alternatives for the given name, if
    # needed
    #
    # @param name [String] original name to be searched
    # @param with_alternatives [Boolean] whether to include alternative names in
    #   the search to be performed
    # @return [Array<String>] the original name followed by all the alternative
    #   names that must be taken into account
    def all_names(name, with_alternatives)
      all = [name]
      all.concat(alternative_names(name)) if with_alternatives
      all
    end

    # Auxiliary method to call a given finder in a list of names, returning the
    # first found device
    #
    # @param finder [Symbol, String] name of the finder
    # @param names [Array<String>] names of device to search for
    # @return [Device, nil] device matching any of the names, nil if no device was found
    def call_finder(finder, names)
      names.each do |name|
        device = send(:"#{finder}_finder", name)
        if device
          log.debug "Device #{names.first} found as #{name} by finder #{finder}: #{device.inspect}"
          return device
        end
      end

      nil
    end

    # Finder method: performs the search of a device based on its #name method
    #
    # @see #call_finder
    #
    # @return [Device, nil] device matching the name, nil if no device was found
    def name_finder(name)
      BlkDevice.find_by_name(devicegraph, name) || devicegraph.lvm_vgs.find { |vg| vg.name == name }
    end

    # Finder method: performs the search of a device by any of its device names (kernel name or
    # udev name) already present in the devicegraph
    #
    # @see #call_finder
    #
    # @return [Device, nil] device matching the name, nil if no device was found
    def all_names_finder(name)
      # First check using the device name
      device = name_finder(name)
      # If not found, check udev names directly handled by libstorage-ng
      device ||= devicegraph.blk_devices.find { |dev| dev.udev_full_all.include?(name) }

      device
    end

    # Finder method: performs a system lookup to find a device matching the given name
    #
    # @see BlkDevice.find_by_any_name
    # @see #call_finder
    #
    # @return [Device, nil] device matching the name, nil if no device was found
    def system_lookup_finder(name)
      probed = StorageManager.instance.raw_probed
      device = BlkDevice.find_by_any_name(probed, name)

      return nil if device.nil?

      devicegraph.find_device(device.sid)
    end

    # Whether it's reasonably safe to use BlkDevice.find_by_any_name
    #
    # @return [Boolean]
    def udev_lookup_possible?
      # Checking when the operation is safe is quite tricky, since we must
      # ensure than the list of block devices in #probed matches 1:1 the list
      # of block devices in the system.
      #
      # Although it's not 100% precise, checking whether commit has not been
      # called provides a seasonable result.
      !StorageManager.instance.committed?
    end

    # Alternative versions of the name to be also considered in searchs
    #
    # @param device_name [String] a kernel name, udev name or any other device name
    # @return [Array<String>]
    def alternative_names(device_name)
      alternative_enc_names(device_name) + alternative_md_names(device_name)
    end

    # Alternative names for encryption devices
    #
    # @see #alternative_names
    #
    # Encryption devices might be probed with a name that does not match the device name
    # indicated in the fstab file. For example, /etc/fstab could have an entry like:
    #
    #   /dev/mapper/cr_home   /home   ext4  defaults  0   0
    #
    # But that encryption device could be probed with a name like /dev/mapper/cr-auto-1. In that
    # case, the device could not be found in the devicegraph when searching for the device name in the
    # fstab entry. But, if the crypttab file was previously parsed (see Encryption#save_crypttab_names),
    # the Encryption devices are populated in the devicegraph with their corresponding name indicated
    # in the crypttab. This information can be used to try possible alternative names for the encryption
    # device. For example, when the devicegraph contains a Encryption layer /dev/mapper/cr-auto-1 over
    # the device /dev/sda1, and the /etc/crypttab has the following entry:
    #
    #   cr_home   /dev/sda1
    #
    # a possible alternative name for /dev/mapper/cr_home would be /dev/mapper/cr-auto-1, due to the
    # encryption device cr-auto-1 has "cr_home" as crypttab_name (after parsing the crypttab file).
    #
    # @param device_name [String] a kernel name or udev name
    # @return [Array<String>]
    def alternative_enc_names(device_name)
      devices = devicegraph.encryptions.select do |enc|
        enc.crypttab_name? && device_name.include?(enc.crypttab_name)
      end

      devices.map { |d| device_name.sub(d.crypttab_name, d.dm_table_name) }
    end

    # Alternative names for MD devices
    #
    # @see #alternative_names
    #
    # @param device_name [String] a kernel name or link to it
    # @return [Array<String>]
    def alternative_md_names(device_name)
      case Pathname.new(device_name).cleanpath.to_s
      when /^\/dev\/md(\d+)$/
        ["/dev/md/#{Regexp.last_match(1)}"]
      when /^\/dev\/md\/(\d+)$/
        ["/dev/md#{Regexp.last_match(1)}"]
      else
        []
      end
    end
  end
end
