#
# Author:: Nuo Yan <nuo@chef.io>
# Author:: Bryan McLellan <btm@loftninjas.org>
# Author:: Seth Chisamore <schisamo@chef.io>
# Copyright:: Copyright 2010-2017, Chef Software Inc.
# License:: Apache License, Version 2.0
#
# 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.
#

require "chef/provider/service/simple"
require "chef/win32_service_constants"
if RUBY_PLATFORM =~ /mswin|mingw32|windows/
  require "chef/win32/error"
  require "win32/service"
end

class Chef::Provider::Service::Windows < Chef::Provider::Service
  provides :service, os: "windows"
  provides :windows_service

  include Chef::Mixin::ShellOut
  include Chef::ReservedNames::Win32::API::Error rescue LoadError
  include Chef::Win32ServiceConstants

  # Win32::Service.get_start_type
  AUTO_START = "auto start".freeze
  MANUAL = "demand start".freeze
  DISABLED = "disabled".freeze

  # Win32::Service.get_current_state
  RUNNING = "running".freeze
  STOPPED = "stopped".freeze
  CONTINUE_PENDING = "continue pending".freeze
  PAUSE_PENDING = "pause pending".freeze
  PAUSED = "paused".freeze
  START_PENDING = "start pending".freeze
  STOP_PENDING  = "stop pending".freeze

  TIMEOUT = 60

  SERVICE_RIGHT = "SeServiceLogonRight".freeze

  def load_current_resource
    @current_resource = Chef::Resource::WindowsService.new(new_resource.name)
    current_resource.service_name(new_resource.service_name)

    if Win32::Service.exists?(current_resource.service_name)
      current_resource.running(current_state == RUNNING)
      logger.trace "#{new_resource} running: #{current_resource.running}"
      case current_startup_type
      when :automatic
        current_resource.enabled(true)
      when :disabled
        current_resource.enabled(false)
      end
      logger.trace "#{new_resource} enabled: #{current_resource.enabled}"

      config_info = Win32::Service.config_info(current_resource.service_name)
      current_resource.service_type(get_service_type(config_info.service_type))    if config_info.service_type
      current_resource.startup_type(start_type_to_sym(config_info.start_type))     if config_info.start_type
      current_resource.error_control(get_error_control(config_info.error_control)) if config_info.error_control
      current_resource.binary_path_name(config_info.binary_path_name) if config_info.binary_path_name
      current_resource.load_order_group(config_info.load_order_group) if config_info.load_order_group
      current_resource.dependencies(config_info.dependencies)         if config_info.dependencies
      current_resource.run_as_user(config_info.service_start_name)    if config_info.service_start_name
      current_resource.display_name(config_info.display_name)         if config_info.display_name
      current_resource.delayed_start(current_delayed_start)           if current_delayed_start
    end

    current_resource
  end

  def start_service
    if Win32::Service.exists?(@new_resource.service_name)
      # reconfiguration is idempotent, so just do it.
      new_config = {
        service_name: @new_resource.service_name,
        service_start_name: @new_resource.run_as_user,
        password: @new_resource.run_as_password,
      }.reject { |k, v| v.nil? || v.length == 0 }

      Win32::Service.configure(new_config)
      logger.info "#{@new_resource} configured."

      # LocalSystem is the default runas user, which is a special service account that should ultimately have the rights of BUILTIN\Administrators, but we wouldn't see that from get_account_right
      if new_config.key?(:service_start_name) && new_config[:service_start_name].casecmp("localsystem") != 0
        unless Chef::ReservedNames::Win32::Security.get_account_right(canonicalize_username(new_config[:service_start_name])).include?(SERVICE_RIGHT)
          grant_service_logon(new_config[:service_start_name])
        end
      end

      state = current_state
      if state == RUNNING
        logger.trace "#{@new_resource} already started - nothing to do"
      elsif state == START_PENDING
        logger.trace "#{@new_resource} already sent start signal - waiting for start"
        wait_for_state(RUNNING)
      elsif state == STOPPED
        if @new_resource.start_command
          logger.trace "#{@new_resource} starting service using the given start_command"
          shell_out!(@new_resource.start_command)
        else
          spawn_command_thread do
            begin
              Win32::Service.start(@new_resource.service_name)
            rescue SystemCallError => ex
              if ex.errno == ERROR_SERVICE_LOGON_FAILED
                logger.error ex.message
                raise Chef::Exceptions::Service,
                "Service #{@new_resource} did not start due to a logon failure (error #{ERROR_SERVICE_LOGON_FAILED}): possibly the specified user '#{@new_resource.run_as_user}' does not have the 'log on as a service' privilege, or the password is incorrect."
              else
                raise ex
              end
            end
          end
          wait_for_state(RUNNING)
        end
        @new_resource.updated_by_last_action(true)
      else
        raise Chef::Exceptions::Service, "Service #{@new_resource} can't be started from state [#{state}]"
      end
    else
      logger.trace "#{@new_resource} does not exist - nothing to do"
    end
  end

  def stop_service
    if Win32::Service.exists?(@new_resource.service_name)
      state = current_state
      if state == RUNNING
        if @new_resource.stop_command
          logger.trace "#{@new_resource} stopping service using the given stop_command"
          shell_out!(@new_resource.stop_command)
        else
          spawn_command_thread do
            Win32::Service.stop(@new_resource.service_name)
          end
          wait_for_state(STOPPED)
        end
        @new_resource.updated_by_last_action(true)
      elsif state == STOPPED
        logger.trace "#{@new_resource} already stopped - nothing to do"
      elsif state == STOP_PENDING
        logger.trace "#{@new_resource} already sent stop signal - waiting for stop"
        wait_for_state(STOPPED)
      else
        raise Chef::Exceptions::Service, "Service #{@new_resource} can't be stopped from state [#{state}]"
      end
    else
      logger.trace "#{@new_resource} does not exist - nothing to do"
    end
  end

  def restart_service
    if Win32::Service.exists?(@new_resource.service_name)
      if @new_resource.restart_command
        logger.trace "#{@new_resource} restarting service using the given restart_command"
        shell_out!(@new_resource.restart_command)
      else
        stop_service
        start_service
      end
      @new_resource.updated_by_last_action(true)
    else
      logger.trace "#{@new_resource} does not exist - nothing to do"
    end
  end

  def enable_service
    if Win32::Service.exists?(@new_resource.service_name)
      set_startup_type(:automatic)
    else
      logger.trace "#{@new_resource} does not exist - nothing to do"
    end
  end

  def disable_service
    if Win32::Service.exists?(@new_resource.service_name)
      set_startup_type(:disabled)
    else
      logger.trace "#{@new_resource} does not exist - nothing to do"
    end
  end

  action :create do
    if Win32::Service.exists?(new_resource.service_name)
      logger.trace "#{new_resource} already exists - nothing to do"
      return
    end

    converge_by("create service #{new_resource.service_name}") do
      Win32::Service.new(windows_service_config)
    end

    converge_delayed_start
  end

  action :delete do
    unless Win32::Service.exists?(new_resource.service_name)
      logger.trace "#{new_resource} does not exist - nothing to do"
      return
    end

    converge_by("delete service #{new_resource.service_name}") do
      Win32::Service.delete(new_resource.service_name)
    end
  end

  action :configure do
    unless Win32::Service.exists?(new_resource.service_name)
      logger.warn "#{new_resource} does not exist. Maybe you need to prepend action :create"
      return
    end

    # Until #6300 is solved this is required
    if new_resource.run_as_user == new_resource.class.properties[:run_as_user].default
      new_resource.run_as_user = new_resource.class.properties[:run_as_user].default
    end

    converge_if_changed :service_type, :startup_type, :error_control,
                        :binary_path_name, :load_order_group, :dependencies,
                        :run_as_user, :display_name, :description do
      Win32::Service.configure(windows_service_config(:configure))
    end

    converge_delayed_start
  end

  def action_enable
    if current_startup_type != :automatic
      converge_by("enable service #{@new_resource}") do
        enable_service
        logger.info("#{@new_resource} enabled")
      end
    else
      logger.trace("#{@new_resource} already enabled - nothing to do")
    end
    load_new_resource_state
    @new_resource.enabled(true)
  end

  def action_disable
    if current_startup_type != :disabled
      converge_by("disable service #{@new_resource}") do
        disable_service
        logger.info("#{@new_resource} disabled")
      end
    else
      logger.trace("#{@new_resource} already disabled - nothing to do")
    end
    load_new_resource_state
    @new_resource.enabled(false)
  end

  def action_configure_startup
    startup_type = @new_resource.startup_type
    if current_startup_type != startup_type
      converge_by("set service #{@new_resource} startup type to #{startup_type}") do
        set_startup_type(startup_type)
      end
    else
      logger.trace("#{@new_resource} startup_type already #{startup_type} - nothing to do")
    end

    converge_delayed_start

    # Avoid changing enabled from true/false for now
    @new_resource.enabled(nil)
  end

  private

  def current_delayed_start
    if service = Win32::Service.services.find { |x| x.service_name == new_resource.service_name }
      service.delayed_start == 0 ? false : true
    else
      nil
    end
  end

  def grant_service_logon(username)
    begin
      Chef::ReservedNames::Win32::Security.add_account_right(canonicalize_username(username), SERVICE_RIGHT)
    rescue Chef::Exceptions::Win32APIError => err
      logger.fatal "Logon-as-service grant failed with output: #{err}"
      raise Chef::Exceptions::Service, "Logon-as-service grant failed for #{username}: #{err}"
    end

    logger.info "Grant logon-as-service to user '#{username}' successful."
    true
  end

  # remove characters that make for broken or wonky filenames.
  def clean_username_for_path(username)
    username.gsub(/[\/\\. ]+/, "_")
  end

  def canonicalize_username(username)
    username.sub(/^\.?\\+/, "")
  end

  def current_state
    Win32::Service.status(@new_resource.service_name).current_state
  end

  def current_startup_type
    start_type = Win32::Service.config_info(@new_resource.service_name).start_type
    start_type_to_sym(start_type)
  end

  # Helper method that waits for a status to change its state since state
  # changes aren't usually instantaneous.
  def wait_for_state(desired_state)
    retries = 0
    loop do
      break if current_state == desired_state
      raise Timeout::Error if ( retries += 1 ) > resource_timeout
      sleep 1
    end
  end

  def resource_timeout
    @resource_timeout ||= @new_resource.timeout || TIMEOUT
  end

  def spawn_command_thread
    worker = Thread.new do
      yield
    end

    Timeout.timeout(resource_timeout) do
      worker.join
    end
  end

  # @param type [Symbol]
  # @return [Integer]
  # @raise [Chef::Exceptions::ConfigurationError] if the startup type is
  #   not supported.
  # @see Chef::Resource::WindowsService::ALLOWED_START_TYPES
  def startup_type_to_int(type)
    Chef::Resource::WindowsService::ALLOWED_START_TYPES.fetch(type) do
      raise Chef::Exceptions::ConfigurationError, "#{@new_resource.name}: Startup type '#{type}' is not supported"
    end
  end

  # Takes Win32::Service start_types
  def set_startup_type(type)
    startup_type = startup_type_to_int(type)

    logger.trace "#{@new_resource.name} setting start_type to #{type}"
    Win32::Service.configure(
      service_name: @new_resource.service_name,
      start_type: startup_type
    )
    @new_resource.updated_by_last_action(true)
  end

  def windows_service_config(action = :create)
    config = {}

    config[:service_name]       = new_resource.service_name
    config[:display_name]       = new_resource.display_name                      if new_resource.display_name
    config[:service_type]       = new_resource.service_type                      if new_resource.service_type
    config[:start_type]         = startup_type_to_int(new_resource.startup_type) if new_resource.startup_type
    config[:error_control]      = new_resource.error_control                     if new_resource.error_control
    config[:binary_path_name]   = new_resource.binary_path_name                  if new_resource.binary_path_name
    config[:load_order_group]   = new_resource.load_order_group                  if new_resource.load_order_group
    config[:dependencies]       = new_resource.dependencies                      if new_resource.dependencies
    config[:service_start_name] = new_resource.run_as_user                       unless new_resource.run_as_user.empty?
    config[:password]           = new_resource.run_as_password                   unless new_resource.run_as_user.empty? || new_resource.run_as_password.empty?
    config[:description]        = new_resource.description                       if new_resource.description

    case action
    when :create
      config[:desired_access] = new_resource.desired_access if new_resource.desired_access
    end

    config
  end

  def converge_delayed_start
    config = {}
    config[:service_name]  = new_resource.service_name
    config[:delayed_start] = new_resource.delayed_start ? 1 : 0

    # Until #6300 is solved this is required
    if new_resource.delayed_start == new_resource.class.properties[:delayed_start].default
      new_resource.delayed_start = new_resource.class.properties[:delayed_start].default
    end

    converge_if_changed :delayed_start do
      Win32::Service.configure(config)
    end
  end

  # @return [Symbol]
  def start_type_to_sym(start_type)
    case start_type
    when "auto start"
      :automatic
    when "boot start"
      raise("Unsupported start type, #{start_type}. Submit bug request to fix.")
    when "demand start"
      :manual
    when "disabled"
      :disabled
    when "system start"
      raise("Unsupported start type, #{start_type}. Submit bug request to fix.")
    else
      raise("Unsupported start type, #{start_type}. Submit bug request to fix.")
    end
  end

  def get_service_type(service_type)
    case service_type
    when "file system driver"
      SERVICE_FILE_SYSTEM_DRIVER
    when "kernel driver"
      SERVICE_KERNEL_DRIVER
    when "own process"
      SERVICE_WIN32_OWN_PROCESS
    when "share process"
      SERVICE_WIN32_SHARE_PROCESS
    when "recognizer driver"
      SERVICE_RECOGNIZER_DRIVER
    when "driver"
      SERVICE_DRIVER
    when "win32"
      SERVICE_WIN32
    when "all"
      SERVICE_TYPE_ALL
    when "own process, interactive"
      SERVICE_INTERACTIVE_PROCESS | SERVICE_WIN32_OWN_PROCESS
    when "share process, interactive"
      SERVICE_INTERACTIVE_PROCESS | SERVICE_WIN32_SHARE_PROCESS
    else
      raise("Unsupported service type, #{service_type}. Submit bug request to fix.")
    end
  end

  # @return [Integer]
  def get_start_type(start_type)
    case start_type
    when "auto start"
      SERVICE_AUTO_START
    when "boot start"
      SERVICE_BOOT_START
    when "demand start"
      SERVICE_DEMAND_START
    when "disabled"
      SERVICE_DISABLED
    when "system start"
      SERVICE_SYSTEM_START
    else
      raise("Unsupported start type, #{start_type}. Submit bug request to fix.")
    end
  end

  def get_error_control(error_control)
    case error_control
    when "critical"
      SERVICE_ERROR_CRITICAL
    when "ignore"
      SERVICE_ERROR_IGNORE
    when "normal"
      SERVICE_ERROR_NORMAL
    when "severe"
      SERVICE_ERROR_SEVERE
    else
      nil
    end
  end

end
