# "System" helpers.
module System
  class CommandError < StandardError
  end
  class CommandNotFound < CommandError
  end
  class MountError < StandardError
  end

  def self.prettify_command(*args)
    args = args.dup
    # Removes the environment hash, if present.
    args.shift if args.first.is_a?(Hash)
    if args.length == 1
      args.first
    else
      args.shelljoin
    end
  end

  # Runs and pretty-prints a command. Parameters and shelling-out have the same
  # meaning as with +Kernel#spawn+; one parameter is shelling-out, multiple is
  # direct +exec+.
  #
  # @param args [Array<String>] Command and parameters
  # @raise [System::CommandNotFound] on exit status 127, commonly used for command not found.
  # @raise [System::CommandError] on any other exit status.
  def self.run(*args)
    pretty_command = prettify_command(*args)
    $logger.debug(" $ #{pretty_command}")
    unless system(*args)
      raise CommandError.new("Could not execute `#{pretty_command}`, status nil") if $?.nil?
      status = $?.exitstatus
      if status == 127
        raise CommandNotFound.new("Command not found... `#{pretty_command}` (#{status})")
      else
        raise CommandError.new("Command failed... `#{pretty_command}` (#{status})")
      end
    end
  end

  # Execs and pretty-prints a command.
  def self.exec(*args)
    $logger.debug(" $ #{prettify_command(*args)}")
    Kernel.exec(*args)
  end

  # Thin wrapper over +Kernel#exec+, but with printing.
  def self.spawn(*args)
    $logger.debug(" $ #{prettify_command(*args)} &")
    Kernel.spawn(*args)
  end

  # Runs a long-running task in the background while we keep the progress
  # reporting active.
  def self.run_long_running(*args)
    pretty_command = prettify_command(*args)
    pid = System.spawn(*args)
    ret = nil

    loop do
      # Update progress
      Progress.send_state()
      # Look at the status
      break if ret = Process.wait(pid, Process::WNOHANG)
      # Don't loop too tightly
      sleep(0.1)
    end

    status = $?.exitstatus
    if status == 127
      raise CommandNotFound.new("Command not found... `#{pretty_command}` (#{status})")
    elsif !$?.success?
      raise CommandError.new("Command failed... `#{pretty_command}` (#{status})")
    end
  end

  # Discovers the location of given program name.
  def self.which(program_name)
    (ENV["PATH"] or "").split(":").each do |path|
      full = File.join(path, program_name)
      if File.exist?(full) && !File.directory?(full) && File.stat(full).executable? then
        return full
      end
    end
    nil
  end

  def self.write(file, contents)
    $logger.debug(" $ echo #{contents.to_json} > #{file}")
    File.write(file, contents)
  end

  def self.symlink(source, destination)
    $logger.debug(" $ ln -s #{source} #{destination}")
    File.symlink(source, destination)
  end

  # Lists all mount points.
  # This handles temporarily mounting /proc if required.
  # This will hide /proc in those instances.
  # The return format is a hash, with keys being mount point paths,
  # and values being their respective line from /proc/mounts.
  def self.mount_points()
    unless File.exist?("/.proc/mounts")
      $logger.debug("Mounting private procfs at /.proc...")
      FileUtils.mkdir_p("/.proc")
      run("mount", "-t", "proc", "proc", "/.proc")
    end
    result = File.read("/.proc/mounts").split("\n")

    result = result.map do |line|
      [
        # Safe to split by space, spaces are escaped in the mount point.
        # "/tmp/test a b" ->> tmpfs /tmp/test\040a\040b tmpfs rw,relatime 0 0
        line.split(" ")[1].gsub('\040', " "),
        line
      ]
    end.to_h

    result
  end

  # Deletes files or directories indiscriminately.
  # Directories still need to be emptied beforehand.
  def self.delete(*paths)
    paths.each do |path|
      # A symlink can be directory?() true, but won't `Dir.delete()`
      # Thus the weird conditional.
      if File.symlink?(path) || !File.directory?(path)
        $logger.debug(" $ rm #{path.shellescape}")
        File.delete(path)
      else
        $logger.debug(" $ rmdir #{path.shellescape}")
        Dir.delete(path)
      end
    end
  end

  # Mounts a filesystem of type +type+ on +dest+.
  #
  # The +source+ parameter is optional, though kept first to keep a coherent
  # order compared to the actual +mount+ command.
  #
  # @overload mount(source, dest, type:)
  #   @param source [String] (Optional) mount source, defaults to type
  #   @param dest [String] Destination path to mount to
  #   @param type [String] Type of the mount (+-t+).
  #   @param options [Array<String>] Mount options (+-o+).
  # @overload mount(dest, type:)
  #   @param dest [String] Destination path to mount to
  #   @param type [String] Type of the mount (+-t+).
  #   @param options [Array<String>] Mount options (+-o+).
  def self.mount(source, dest = nil, type: nil, options: [])
    # Fill-in the "reversed" optional parameters.
    unless dest
      dest = source
      source = type
    end

    if source.nil? and type.nil?
      raise MountError.new("Cannot mount when missing both source and type.")
    end

    args = []
    if type
      args << "-t"
      args << type
    end

    # Fixup overlayfs to be relative to SYSTEM_MOUNT_POINT.
    # Also handle deeper structure creation.
    options = options.map do |option|
      if [
        "lowerdir",
        "upperdir",
        "workdir",
      ].include?(option.split(/=/).first)
        option, value = option.split(/=/, 2)
        effective = File.join(Tasks::SwitchRoot::SYSTEM_MOUNT_POINT, value)
        FileUtils.mkdir_p(effective)
        [
          option,
          effective,
        ].join("=")
      else
        option
      end
    end

    # Filter options that busybox mount can't handle.
    options = options.select { |option| !option.match(/^x-/) }
    unless options.empty?
      args << "-o"
      args << options.join(",")
    end

    args << source
    args << dest

    # The kernel module for the filesystem may need to be probed.
    begin
      System.run("modprobe", type)
    rescue System::CommandError
      $logger.warn("Kernel filesystem module “#{type}” failed to load.")
    end

    # We may have some mountpoints already mounted from, e.g. early logging in
    # /run/log... If we're not careful we will mount over the existing mount
    # point and hide the resulting files.
    # Side-step by re-mounting to have the appropriate options.
    if mount_points.keys.include?(dest)
      $logger.debug("#{dest} already mounted, remounting.")
      run("mount", "-o", "remount", *args)
    else
      run("mount", *args)
    end
  end

  # Unmounts a mount.
  def self.umount(target, *args)
    run("umount", target, *args)
  end

  def self.cmdline()
    if File.exist?("/proc/cmdline") then
      File.read("/proc/cmdline").split(/\s+/)
    else
      []
    end
  end

  def self.failure(code, title, message="(No details given)", color: "000000", delay: Configuration["boot"]["fail"]["delay"], status: 111)
    $logger.debug("-- Entering System.failure handler --")
    $logger.debug("Killing the splash applet...")
    Progress.kill()

    # First print the error we're handling.
    # The added asterisks seve to aid in finding it visually.
    5.times do
      $logger.fatal("********************************************")
    end
    $logger.fatal("")
    $logger.fatal("********************************************")
    $logger.fatal("* Fatal error in Mobile NixOS stage-1 init *")
    $logger.fatal("********************************************")
    $logger.fatal("")
    $logger.fatal("#{code}: #{title}")
    $logger.fatal("")
    $logger.fatal(message)
    $logger.fatal("")
    5.times do
      $logger.fatal("********************************************")
    end

    _flush_outputs()

    File.write("/.error.json", {
      code: code,
      title: title,
      color: color,
      delay: delay,
      message: message,
      status: status,
    }.to_json)

    # Drop down to a shell if possible and wanted.
    shell if respond_to?(:shell) && Configuration["boot"]["shellOnFail"]

    # Show the error handler applet.
    begin
      System.exec(LOADER, "/applets/boot-error.mrb", "/.error.json")
    rescue => e
      $logger.fatal("********************************************")
      $logger.fatal("* Error handler failed to start            *")
      $logger.fatal("********************************************")
      $logger.fatal(e.inspect)
      $logger.fatal("********************************************")
    end

    # If we're here, things are broken beyond belief!
    _flush_outputs()

    # As in "command not found".
    exit 127
  end

  # Flushes the outputs.
  def self._flush_outputs()
    # Flush both output
    $stdout.flush
    $stderr.flush
  end

  def self.hard_reboot()
    System.write("/proc/sysrq-trigger", "b\n")
  end
end
