/* -*- Mode: Vala; indent-tabs-mode: nil; tab-width: 2 -*-
 *
 * SPDX-License-Identifier: GPL-3.0-or-later
 * SPDX-FileCopyrightText: Michael Terry
 */

using GLib;

string get_srcdir()
{
  var srcdir = Environment.get_variable("srcdir");
  if (srcdir == null)
    srcdir = ".";
  return srcdir;
}

KeyFile load_script()
{
  try {
    var script = Environment.get_variable("DEJA_DUP_TEST_SCRIPT");
    var keyfile = new KeyFile();
    keyfile.load_from_file(script, KeyFileFlags.KEEP_COMMENTS);
    return keyfile;
  }
  catch (Error e) {
    warning("%s\n", e.message);
    assert_not_reached();
  }
}

void backup_setup()
{
  // Intentionally don't create @TEST_HOME@/backup, as the mkdir test relies
  // on us not doing so.

  var dir = Environment.get_variable("DEJA_DUP_TEST_HOME");

  Environment.set_variable("DEJA_DUP_TEST_MOCKSCRIPT", Path.build_filename(dir, "mockscript"), true);
  Environment.set_variable("XDG_CACHE_HOME", Path.build_filename(dir, "cache"), true);
  Environment.set_variable("XDG_CONFIG_HOME", Path.build_filename(dir, "config"), true);
  Environment.set_variable("XDG_DATA_HOME", Path.build_filename(dir, "data"), true);
  Environment.set_variable("PATH",
                           get_srcdir() + "/mock:" +
                             Environment.get_variable("DEJA_DUP_TEST_PATH"),
                           true);

  var tempdir = Path.build_filename(dir, "tmp");
  DejaDup.ensure_directory_exists(tempdir);
  Environment.set_variable("DEJA_DUP_TEMPDIR", tempdir, true);

  // Add a couple cache folders to confirm that we always exclude them.
  DejaDup.ensure_directory_exists(Path.build_filename(dir, "cache"));
  DejaDup.ensure_directory_exists(Path.build_filename(dir, "cache", Config.PACKAGE));

  run_script("xdg-user-dirs-update");

  var settings = DejaDup.get_settings();
  settings.set_string(DejaDup.BACKEND_KEY, "local");
  settings = DejaDup.get_settings(DejaDup.LOCAL_ROOT);
  settings.set_string(DejaDup.LOCAL_FOLDER_KEY, Path.build_filename(dir, "backup"));
}

void backup_teardown()
{
  Environment.set_variable("PATH",
                           Environment.get_variable("DEJA_DUP_TEST_PATH"),
                           true);

  var mockscript_path = Environment.get_variable("DEJA_DUP_TEST_MOCKSCRIPT");
  var file = File.new_for_path(mockscript_path);
  if (file.query_exists(null)) {
    // Fail the test, something went wrong
    warning("Mockscript file %s still exists", mockscript_path);
  }

  file = File.new_for_path(mockscript_path + ".failed");
  if (file.query_exists(null)) {
    // Fail the test, something went wrong
    warning("Mockscript had fatal error");
  }

  var test_home = Environment.get_variable("DEJA_DUP_TEST_HOME");
  file = File.new_for_path(Path.build_filename(test_home, "backup"));
  if (file.query_exists(null)) {
    try {
      file.delete(null);
    }
    catch (Error e) {
      assert_not_reached();
    }
  }

  if (Posix.system("rm -r --interactive=never %s".printf(Environment.get_variable("DEJA_DUP_TEST_HOME"))) != 0)
    warning("Could not clean TEST_HOME %s", Environment.get_variable("DEJA_DUP_TEST_HOME"));

  Environment.unset_variable("DEJA_DUP_TEST_MOCKSCRIPT");
  Environment.unset_variable("XDG_CACHE_HOME");
  Environment.unset_variable("XDG_CONFIG_HOME");
  Environment.unset_variable("XDG_DATA_HOME");
}

public enum Mode {
  NONE,
  LIST_SNAPSHOTS,
  DRY,
  BACKUP,
  VERIFY,
  CLEANUP,
  REMOVE,
  RESTORE,
  RESTORE_STATUS,
  LIST_FILES,
}

string duplicity_escape(string path)
{
  var escaped = path.replace("[", "[[]");
  escaped = escaped.replace("?", "[?]");
  escaped = escaped.replace("*", "[*]");
  return escaped;
}

string duplicity_args(BackupRunner br, Mode mode = Mode.NONE, bool encrypted = false,
                      string extra = "", string include_args = "", string exclude_args = "",
                      bool tmp_archive = false, int remove_n = -1, string? file_to_restore = null)
{
  var cachedir = Environment.get_variable("XDG_CACHE_HOME");
  var configdir = Environment.get_variable("XDG_CONFIG_HOME");
  var datadir = Environment.get_variable("XDG_DATA_HOME");
  var test_home = Environment.get_variable("DEJA_DUP_TEST_HOME");
  var restoredir = Path.build_filename(test_home, "restore");

  var local_settings = DejaDup.get_settings(DejaDup.LOCAL_ROOT);
  var backupdir_path = local_settings.get_string(DejaDup.LOCAL_FOLDER_KEY);
  var backupdir = DejaDup.BackendFile.escape_uri_chars(backupdir_path);

  var logfd = "'--log-fd=?'";

  string enc_str = "";
  if (!encrypted)
    enc_str = "--no-encryption ";

  var tempdir = Path.build_filename(test_home, "tmp");
  var archive = tmp_archive ? "%s/duplicity-?".printf(tempdir) : "%s/deja-dup".printf(cachedir);

  var end_str = "%s'--verbosity=9' '--timeout=120' '--archive-dir=%s' '--tempdir=%s' %s"
    .printf(enc_str, archive, tempdir, logfd);

  if (mode == Mode.CLEANUP)
    return "cleanup '--force' 'file://%s' %s".printf(backupdir, end_str);
  else if (mode == Mode.RESTORE) {
    string file_arg = "", dest_arg = "";
    if (file_to_restore != null) {
      file_arg = "'--path-to-restore=%s' ".printf(file_to_restore.substring(1)); // skip root /
      dest_arg = "/" + File.new_for_path(file_to_restore).get_basename();
    }
    return "'restore' %s%s'--no-restore-ownership' '--force' 'file://%s' '%s%s' %s"
      .printf(file_arg, extra, backupdir, restoredir, dest_arg, end_str);
  }
  else if (mode == Mode.VERIFY) {
    var constant_args = "'--no-restore-ownership' '--force'";
    return "'restore' '--path-to-restore=%s/deja-dup/metadata' %s 'file://%s' '%s/deja-dup/metadata' %s"
      .printf(datadir.substring(1), constant_args, backupdir, datadir, end_str);
  }
  else if (mode == Mode.LIST_FILES)
    return "'list-current-files' %s'file://%s' %s".printf(extra, backupdir, end_str);
  else if (mode == Mode.REMOVE)
    return "'remove-all-but-n-full' '%d' '--force' 'file://%s' %s".printf(remove_n, backupdir, end_str);

  string source_str = "";
  if (mode == Mode.DRY || mode == Mode.BACKUP)
    source_str = "--no-files-changed --volsize=1 / ";

  string dry_str = "";
  if (mode == Mode.DRY)
    dry_str = "--dry-run ";

  string args = "";

  if (mode == Mode.BACKUP || mode == Mode.DRY)
    args += br.is_full ? "full " : "backup ";

  if (mode == Mode.LIST_SNAPSHOTS || mode == Mode.RESTORE_STATUS)
    args += "collection-status ";

  string includes = "";
  if (mode == Mode.DRY || mode == Mode.BACKUP) {
    includes += "'--include=%s/deja-dup/metadata' ".printf(datadir);
    includes += "'--exclude=%s/snap/*/*/.cache' ".printf(Environment.get_home_dir());
    includes += "'--exclude=%s/.var/app/*/cache' ".printf(Environment.get_home_dir());
    includes += "'--exclude=%s/snap/*/*/.config/Code Cache' ".printf(Environment.get_home_dir());
    includes += "'--exclude=%s/snap/*/*/.config/Cache' ".printf(Environment.get_home_dir());
    includes += "'--exclude=%s/.var/app/*/config/Code Cache' ".printf(Environment.get_home_dir());
    includes += "'--exclude=%s/.var/app/*/config/Cache' ".printf(Environment.get_home_dir());
    includes += "'--exclude=%s/.config/*/Code Cache' ".printf(Environment.get_home_dir());
    includes += "'--exclude=%s/.config/*/Cache' ".printf(Environment.get_home_dir());
    includes += "'--exclude=%s/*/Code Cache' ".printf(configdir);
    includes += "'--exclude=%s/*/Cache' ".printf(configdir);

    includes += include_args;

    string[] excludes1 = {"~/Downloads", "$TRASH", "~/.xsession-errors", tempdir,
                          "~/.steam/root",
                          "~/.var/app/com.valvesoftware.Steam/.steam/root",
                          "~/.Private", "~/.gvfs", "~/.ccache",
                          "%s/deja-dup".printf(cachedir), "~/.cache"};
    foreach (string ex in excludes1) {
      ex = ex.replace("~", Environment.get_home_dir());
      ex = ex.replace("$TRASH", DejaDup.InstallEnv.instance().get_trash_dir());
      if (FileUtils.test (ex, FileTest.IS_SYMLINK | FileTest.EXISTS))
        includes += "'--exclude=%s' ".printf(ex);
    }

    includes += "'--exclude=%s' ".printf(cachedir);
    includes += "'--exclude=%s' ".printf(duplicity_escape(backupdir_path));

    // Really, this list could be interweaved, depending on
    // what the paths are and the order in gsettings.  But tests are careful
    // to avoid having us duplicate the sorting logic in DuplicityJob by
    // putting /tmp paths at the end of exclude lists.
    includes += exclude_args;

    var sys_sym_excludes = "";
    foreach (string sym in excludes1) {
      sym = sym.replace("~", Environment.get_home_dir());
      if (FileUtils.test (sym, FileTest.IS_SYMLINK) &&
          FileUtils.test (sym, FileTest.EXISTS)) {
        try {
          sym = FileUtils.read_link (sym);
          sym = Filename.to_utf8 (sym, -1, null, null);
          if (sym.has_prefix (Environment.get_home_dir()))
            includes += "'--exclude=%s' ".printf(sym);
          else // delay non-home paths until very end
            sys_sym_excludes += "'--exclude=%s' ".printf(sym);
        }
        catch (Error e) {
          assert_not_reached();
        }
      }
    }

    if (FileUtils.test (Environment.get_home_dir(), FileTest.EXISTS)) {
      includes += "'--include=%s' ".printf(Environment.get_home_dir());
    }

    string[] excludes2 = {"/sys", "/run", "/proc", "/dev"};
    foreach (string ex in excludes2) {
      if (FileUtils.test (ex, FileTest.EXISTS))
        includes += "'--exclude=%s' ".printf(ex);
    }

    includes += sys_sym_excludes;

    includes += "'--exclude=**' ";
    includes += "'--exclude-if-present=CACHEDIR.TAG' ";
    includes += "'--exclude-if-present=.deja-dup-ignore' ";
  }

  args += "%s %s%s%s'file://%s' %s".printf(extra, dry_str, includes, source_str, backupdir, end_str);

  return args;
}

class BackupRunner : Object
{
  public delegate void OpCallback (DejaDup.Operation op);
  public DejaDup.Operation op = null;
  public string path = null;
  public string script = null;
  public string final_script = null;
  public bool success = true;
  public bool cancelled = false;
  public string? detail = null;
  public string? error_str = null;
  public string? error_regex = null;
  public string? error_detail = null;
  public string? restore_tag = null;
  public List<File> restore_files = null;
  public OpCallback? callback = null;
  public bool is_full = false; // we don't often give INFO 3 which triggers is_full()
  public bool is_first = false;
  public int passphrases = 0;

  public void run()
  {
    if (script != null)
      run_script(script);

    if (path != null)
      Environment.set_variable("PATH", path, true);

    DejaDup.initialize();

    if (op != null)
      run_operation();

    if (final_script != null)
      run_script(final_script);
  }

  void run_operation()
  {
    var loop = new MainLoop(null);
    op.done.connect((op, s, c, d) => {
      Test.message("Done: %d, %d, %s", (int)s, (int)c, d);
      if (success != s)
        warning("Success didn't match; expected %d, got %d", (int) success, (int) s);
      if (cancelled != c)
        warning("Cancel didn't match; expected %d, got %d", (int) cancelled, (int) c);
      if (detail != d)
        warning("Detail didn't match; expected %s, got %s", detail, d);
      loop.quit();
    });

    op.raise_error.connect((str, det) => {
      Test.message("Error: %s, %s", str, det);
      if (error_str != null && error_str != str)
        warning("Error string didn't match; expected %s, got %s", error_str, str);
      if (error_regex != null && !GLib.Regex.match_simple (error_regex, str))
        warning("Error string didn't match regex; expected %s, got %s", error_regex, str);
      if (error_detail != det)
        warning("Error detail didn't match; expected %s, got %s", error_detail, det);
      error_str = null;
      error_regex = null;
      error_detail = null;
    });

    op.action_desc_changed.connect((action) => {
    });
    op.action_file_changed.connect((file, actual) => {
    });
    op.progress.connect((percent) => {
    });

    op.passphrase_required.connect((repeat, first) => {
      if (first) {
        op.set_passphrase.begin(null, false);
        return; // ignore the initial report
      }
      Test.message("Passphrase required");
      if (passphrases == 0)
        warning("Passphrase needed but not provided");
      else {
        passphrases--;
        op.set_passphrase.begin("test", false);
      }
    });

    op.question.connect((title, msg) => {
      Test.message("Question asked: %s, %s", title, msg);
      op.resume();
    });

    var seen_is_full = false;
    op.is_full.connect((first) => {
      Test.message("Is full; is first: %d", (int)first);
      if (!is_full)
        warning("IsFull was not expected");
      if (is_first != first)
        warning("IsFirst didn't match; expected %d, got %d", (int) is_first, (int) first);
      seen_is_full = true;
    });

    Idle.add(() => {op.start.begin(); return false;});
    if (callback != null) {
      Timeout.add_seconds(5, () => {
        callback(op);
        return false;
      });
    }
    loop.run();

    if (!seen_is_full && is_full) {
      warning("IsFull was expected");
      if (is_first)
        warning("IsFirst was expected");
    }
    if (error_str != null)
      warning("Error str didn't match; expected %s, never got error", error_str);
    if (error_regex != null)
      warning("Error regex didn't match; expected %s, never got error", error_regex);
    if (error_detail != null)
      warning("Error detail didn't match; expected %s, never got error", error_detail);

    if (passphrases > 0)
      warning("Passphrases expected, but not seen");
  }
}

void add_to_mockscript(string contents)
{
  var script = Environment.get_variable("DEJA_DUP_TEST_MOCKSCRIPT");
  string initial = "";
  try {
    FileUtils.get_contents(script, out initial, null);
    initial += "\n\n=== deja-dup ===";
  }
  catch (Error e) {
    initial = "";
  }

  var real_contents = initial + "\n" + contents;
  try {
    FileUtils.set_contents(script, real_contents);
  }
  catch (Error e) {
    assert_not_reached();
  }
}

string replace_keywords(string in)
{
  var home = Environment.get_home_dir();
  var user = Environment.get_user_name();
  var mockdir = get_srcdir() + "/mock";
  var cachedir = Environment.get_variable("XDG_CACHE_HOME");
  var datadir = Environment.get_variable("XDG_DATA_HOME");
  var test_home = Environment.get_variable("DEJA_DUP_TEST_HOME");
  var path = Environment.get_variable("PATH");
  return in.replace("@HOME@", home).
            replace("@MOCK_DIR@", mockdir).
            replace("@PATH@", path).
            replace("@USER@", user).
            replace("@APPID@", Config.APPLICATION_ID).
            replace("@XDG_CACHE_HOME@", cachedir).
            replace("@XDG_DATA_HOME@", datadir).
            replace("@TEST_HOME@", test_home);
}

string run_script(string in)
{
  string output;
  string errstr;
  int status;
  try {
    Process.spawn_sync(null, {"/bin/sh", "-c", in}, null, 0, null, out output, out errstr, out status);
    if (output != null && output != "")
      print("Output from script: '%s'\n", output);
    if (errstr != null && errstr != "")
      warning("Error running script: %s", errstr);
    Process.check_wait_status(status);
  }
  catch (Error e) {
    warning(e.message);
    assert_not_reached();
  }
  return output;
}

void process_operation_block(KeyFile keyfile, string group, BackupRunner br) throws Error
{
  var test_home = Environment.get_variable("DEJA_DUP_TEST_HOME");
  var restoredir = Path.build_filename(test_home, "restore");

  if (keyfile.has_key(group, "RestoreFiles")) {
    var array = keyfile.get_string_list(group, "RestoreFiles");
    br.restore_files = new List<File>();
    foreach (var file in array)
      br.restore_files.append(File.new_for_path(replace_keywords(file)));
  }
  if (keyfile.has_key(group, "RestoreTag"))
    br.restore_tag = keyfile.get_string(group, "RestoreTag");

  if (keyfile.has_key(group, "Success"))
    br.success = keyfile.get_boolean(group, "Success");
  if (keyfile.has_key(group, "Canceled"))
    br.cancelled = keyfile.get_boolean(group, "Canceled");
  if (keyfile.has_key(group, "IsFull"))
    br.is_full = keyfile.get_boolean(group, "IsFull");
  if (keyfile.has_key(group, "IsFirst"))
    br.is_first = keyfile.get_boolean(group, "IsFirst");
  if (keyfile.has_key(group, "Detail"))
    br.detail = replace_keywords(keyfile.get_string(group, "Detail"));
  if (keyfile.has_key(group, "DiskFree"))
    Environment.set_variable("DEJA_DUP_TEST_SPACE_FREE", keyfile.get_string(group, "DiskFree"), true);
  if (keyfile.has_key(group, "Error"))
    br.error_str = keyfile.get_string(group, "Error");
  if (keyfile.has_key(group, "ErrorRegex"))
    br.error_regex = keyfile.get_string(group, "ErrorRegex");
  if (keyfile.has_key(group, "ErrorDetail"))
    br.error_detail = get_string_field(keyfile, group, "ErrorDetail");
  if (keyfile.has_key(group, "FinalScript"))
    br.final_script = get_string_field(keyfile, group, "FinalScript");
  if (keyfile.has_key(group, "Passphrases"))
    br.passphrases = keyfile.get_integer(group, "Passphrases");
  if (keyfile.has_key(group, "Path"))
    br.path = replace_keywords(keyfile.get_string(group, "Path"));
  if (keyfile.has_key(group, "Script"))
    br.script = get_string_field(keyfile, group, "Script");
  if (keyfile.has_key(group, "Settings")) {
    var settings_list = keyfile.get_string_list(group, "Settings");
    foreach (var setting in settings_list) {
      try {
        var tokens = replace_keywords(setting).split("=", 2);
        var key_tokens = tokens[0].split(".");
        var settings = DejaDup.get_settings(key_tokens.length > 1 ? key_tokens[0] : null);
        var key = key_tokens[key_tokens.length - 1];
        var val = Variant.parse(null, tokens[1]);
        settings.set_value(key, val);
      }
      catch (Error e) {
        warning("%s\n", e.message);
        assert_not_reached();
      }
    }
  }
  var type = keyfile.get_string(group, "Type");
  if (type == "backup")
    br.op = new DejaDup.OperationBackup(DejaDup.Backend.get_default(), false);
  else if (type == "restore") {
    // Create a FileTree to pass the restore operation. In the future, we might
    // want to have scripts also walk us through a list operation. But for now,
    // just fake it and say all files are directories.
    var tree = new DejaDup.FileTree();
    foreach (var file in br.restore_files)
      tree.add(file.get_path(), FileType.DIRECTORY);
    br.op = new DejaDup.OperationRestore(DejaDup.Backend.get_default(), restoredir,
                                         tree, br.restore_tag, br.restore_files);
  }
  else if (type == "noop")
    br.op = null;
  else
    assert_not_reached();
}

string get_string_field(KeyFile keyfile, string group, string key) throws Error
{
  var field = keyfile.get_string(group, key);
  if (field == "^")
    return replace_keywords(keyfile.get_comment(group, key));
  if (field == "^sh")
    return run_script(replace_keywords(keyfile.get_comment(group, key))).strip();
  else
    return replace_keywords(field);
}

void process_duplicity_run_block(KeyFile keyfile, string run, BackupRunner br) throws Error
{
  string outputscript = null;
  string extra_args = "";
  string include_args = "";
  string exclude_args = "";
  string file_to_restore = null;
  bool encrypted = false;
  bool stop = false;
  bool passphrase = false;
  bool tmp_archive = false;
  int return_code = 0;
  int remove_n = -1;
  string script = null;
  Mode mode = Mode.NONE;

  var parts = run.split(" ", 2);
  var type = parts[0];
  var group = "Duplicity " + run;

  if (keyfile.has_group(group)) {
    if (keyfile.has_key(group, "ArchiveDirIsTmp"))
      tmp_archive = keyfile.get_boolean(group, "ArchiveDirIsTmp");
    if (keyfile.has_key(group, "Encrypted"))
      encrypted = keyfile.get_boolean(group, "Encrypted");
    if (keyfile.has_key(group, "ExtraArgs")) {
      extra_args = get_string_field(keyfile, group, "ExtraArgs");
      if (!extra_args.has_suffix(" "))
        extra_args += " ";
    }
    if (keyfile.has_key(group, "IncludeArgs")) {
      include_args = get_string_field(keyfile, group, "IncludeArgs");
      if (!include_args.has_suffix(" "))
        include_args += " ";
    }
    if (keyfile.has_key(group, "ExcludeArgs")) {
      exclude_args = get_string_field(keyfile, group, "ExcludeArgs");
      if (!exclude_args.has_suffix(" "))
        exclude_args += " ";
    }
    if (keyfile.has_key(group, "FileToRestore"))
      file_to_restore = get_string_field(keyfile, group, "FileToRestore");
    if (keyfile.has_key(group, "Output") && keyfile.get_boolean(group, "Output"))
      outputscript = replace_keywords(keyfile.get_comment(group, "Output"));
    else if (keyfile.has_key(group, "OutputScript") && keyfile.get_boolean(group, "OutputScript"))
      outputscript = run_script(replace_keywords(keyfile.get_comment(group, "OutputScript")));
    if (keyfile.has_key(group, "Passphrase"))
      passphrase = keyfile.get_boolean(group, "Passphrase");
    if (keyfile.has_key(group, "RemoveButN"))
      remove_n = keyfile.get_integer(group, "RemoveButN");
    if (keyfile.has_key(group, "Return"))
      return_code = keyfile.get_integer(group, "Return");
    if (keyfile.has_key(group, "Stop"))
      stop = keyfile.get_boolean(group, "Stop");
    if (keyfile.has_key(group, "Script"))
      script = get_string_field(keyfile, group, "Script");
  }

  if (type == "status")
    mode = Mode.LIST_SNAPSHOTS;
  else if (type == "status-restore")
    mode = Mode.RESTORE_STATUS; // should really consolidate the statuses
  else if (type == "dry")
    mode = Mode.DRY;
  else if (type == "list")
    mode = Mode.LIST_FILES;
  else if (type == "backup")
    mode = Mode.BACKUP;
  else if (type == "verify")
    mode = Mode.VERIFY;
  else if (type == "remove")
    mode = Mode.REMOVE;
  else if (type == "restore")
    mode = Mode.RESTORE;
  else if (type == "cleanup")
    mode = Mode.CLEANUP;
  else
    assert_not_reached();

  var datadir = Environment.get_variable("XDG_DATA_HOME");

  var dupscript = "ARGS: " + duplicity_args(br, mode, encrypted, extra_args, include_args, exclude_args,
                                          tmp_archive, remove_n, file_to_restore);

  if (tmp_archive)
    dupscript += "\n" + "TMP_ARCHIVE";

  if (stop) {
    dupscript += "\n" + "DELAY: 10";
    br.callback = (op) => {
      op.stop();
    };
  }

  if (return_code != 0)
    dupscript += "\n" + "RETURN: %d".printf(return_code);

  var verify_script = ("mkdir -p %s/deja-dup/metadata && " +
                       "echo 'This folder can be safely deleted.' > %s/deja-dup/metadata/README && " +
                       "echo -n '0' >> %s/deja-dup/metadata/README").printf(datadir, datadir, datadir);
  if (mode == Mode.VERIFY)
    dupscript += "\n" + "SCRIPT: " + verify_script;
  if (script != null) {
    if (mode == Mode.VERIFY)
      dupscript += " && " + script;
    else
      dupscript += "\n" + "SCRIPT: " + script;
  }

  if (passphrase)
    dupscript += "\n" + "PASSPHRASE: test";
  else if (!encrypted) // when not encrypted, we always expect empty string
    dupscript += "\n" + "PASSPHRASE:";

  if (outputscript != null && outputscript != "") {
    // GLib prior to 2.59 added an extra \n to outputscript, but we need \n\n
    // here, so we add them ourselves.
    dupscript += "\n\n" + outputscript.chomp() + "\n\n";
  }

  add_to_mockscript(dupscript);
}

void process_duplicity_block(KeyFile keyfile, string group, BackupRunner br) throws Error
{
  var version = "9.9.99";
  if (keyfile.has_key(group, "Version"))
    version = get_string_field(keyfile, group, "Version");
  if (version.length > 0)
    add_to_mockscript("ARGS: --version\n\nduplicity " + version + "\n");

  if (keyfile.has_key(group, "Error"))
    br.error_str = get_string_field(keyfile, group, "Error");
  if (keyfile.has_key(group, "IsFull"))
    br.is_full = keyfile.get_boolean(group, "IsFull");

  if (keyfile.has_key(group, "Runs")) {
    var runs = keyfile.get_string_list(group, "Runs");
    foreach (var run in runs)
      process_duplicity_run_block(keyfile, run, br);
  }
}

void duplicity_run()
{
  var settings = DejaDup.get_settings();
  settings.set_string(DejaDup.TOOL_WHEN_NEW_KEY, "duplicity");

  try {
    var keyfile = load_script();
    var br = new BackupRunner();

    var groups = keyfile.get_groups();
    foreach (var group in groups) {
      if (group == "Operation")
        process_operation_block(keyfile, group, br);
      else if (group == "Duplicity")
        process_duplicity_block(keyfile, group, br);
    }

    br.run();
  }
  catch (Error e) {
    warning("%s\n", e.message);
    assert_not_reached();
  }
}

string parse_path(string path)
{
  return path.replace("$HOME", Environment.get_home_dir())
             .replace("$DATADIR", Environment.get_user_data_dir())
             .replace("$CACHEDIR", Environment.get_variable("XDG_CACHE_HOME"));
}

string? read_link(string path)
{
  if (!FileUtils.test(path, FileTest.IS_SYMLINK) ||
      !FileUtils.test(path, FileTest.EXISTS))
    return null;

  try {
    var sym = FileUtils.read_link(path);
    sym = Filename.to_utf8(sym, -1, null, null);
    return sym;
  }
  catch (Error e) {
    assert_not_reached();
  }
}

string[] get_string_list(KeyFile keyfile, string group, string key) throws Error
{
  var list = keyfile.get_string_list(group, key);
  string[] replaced = {};
  foreach (var item in list) {
    replaced += replace_keywords(item);
  }
  return replaced;
}

string? restic_exc(string path, bool must_exist=true)
{
  var parsed = parse_path(path);

  if (must_exist && !FileUtils.test(parsed, FileTest.IS_SYMLINK | FileTest.EXISTS))
    return null;

  return parsed;
}

string restic_escape(string path)
{
  var escaped = path.replace("\\", "\\\\");
  escaped = escaped.replace("*", "\\*");
  escaped = escaped.replace("?", "\\?");
  escaped = escaped.replace("[", "\\[");
  escaped = escaped.replace("$", "$$");
  return escaped;
}


List<string> restic_exc_list(string[] paths, bool add_wildcard = true, bool negate = false, bool escape = true)
{
  List<string> args = null;

  foreach (var path in paths) {
    if (path == null)
      continue;

    var parsed = parse_path(path);

    var target = read_link(parsed);
    if (target != null) {
      args.append("'--exclude=%s%s'".printf(negate ? "!" : "", target));
      if (add_wildcard)
        args.append("'--exclude=%s/*'".printf(target));
    }

    var escaped = escape ? restic_escape(parsed) : parsed;

    args.append("'--exclude=%s%s'".printf(negate ? "!" : "", escaped));
    if (add_wildcard)
      args.append("'--exclude=%s/*'".printf(escaped));
  }

  return args;
}

// Copy of cmp_prefix() in ResticJob
int restic_path_cmp(string a, string b)
{
  string a_copy = null;
  string b_copy = null;
  unowned string a_cmp = a.offset(11); // skip '--exclude=
  unowned string b_cmp = b.offset(11);
  if (a_cmp[0] == '!')
    a_cmp = a_cmp.offset(1);
  if (b_cmp[0] == '!')
    b_cmp = b_cmp.offset(1);

  if (a_cmp.has_suffix("/*'")) {
    a_copy = a_cmp.substring(0, a_cmp.length - 2);
    a_cmp = a_copy;
  }
  if (b_cmp.has_suffix("/*'")) {
    b_copy = b_cmp.substring(0, b_cmp.length - 2);
    b_cmp = b_copy;
  }

  return strcmp(a_cmp, b_cmp);
}

string restic_args(BackupRunner br, string mode, string[] extra_excludes,
                   string[] sym_target_excludes, string[] extra_includes,
                   int keep_last, int keep_within, string? file_to_restore,
                   string? snapshot, bool no_password)
{
  var cachedir = Environment.get_variable("XDG_CACHE_HOME");
  var configdir = Environment.get_variable("XDG_CONFIG_HOME");
  var test_home = Environment.get_variable("DEJA_DUP_TEST_HOME");
  var tempdir = Path.build_filename(test_home, "tmp");
  var host = Environment.get_host_name();

  var local_settings = DejaDup.get_settings(DejaDup.LOCAL_ROOT);
  var backupdir = local_settings.get_string(DejaDup.LOCAL_FOLDER_KEY);

  List<string> args = null;

  args.append("--json");
  args.append("--cleanup-cache");
  args.append("--cache-dir=%s/deja-dup/restic".printf(cachedir));
  if (no_password)
    args.append("--insecure-no-password");
  args.append("'--repo=" + backupdir + "'");

  switch (mode) {
    case "backup":
      args.append("backup");
      args.append("--host=%s".printf(host));
      args.append("--tag=deja-dup");
      args.append("--group-by=host,tags");
      args.append("--exclude-caches");
      args.append("--exclude-if-present=.deja-dup-ignore");

      // includes
      string[] includes = {
        "$HOME"
      };
      foreach (var path in includes) {
        args.append(parse_path(path));
      }
      if (extra_includes != null) {
        foreach (var inc in extra_includes) {
          args.append("'%s'".printf(parse_path(inc)));
        }
      }

      string[] regex_excludes = {
        restic_exc("$HOME/snap/*/*/.cache", false),
        restic_exc("$HOME/.var/app/*/cache", false),
        restic_exc("$HOME/snap/*/*/.config/Code Cache", false),
        restic_exc("$HOME/snap/*/*/.config/Cache", false),
        restic_exc("$HOME/.var/app/*/config/Code Cache", false),
        restic_exc("$HOME/.var/app/*/config/Cache", false),
        restic_exc("$HOME/.config/*/Code Cache", false),
        restic_exc("$HOME/.config/*/Cache", false),
        restic_exc(configdir + "/*/Code Cache", false),
        restic_exc(configdir + "/*/Cache", false),
      };
      string[] default_excludes = {
        restic_exc("$HOME/Downloads"),
        restic_exc(DejaDup.InstallEnv.instance().get_trash_dir())
      };
      string[] builtin_excludes = {
        restic_exc("/sys"),
        restic_exc("/run"),
        restic_exc("/proc"),
        restic_exc("/dev"),
        restic_exc(tempdir),
        restic_exc("$HOME/.xsession-errors"),
        restic_exc("$HOME/.steam/root"),
        restic_exc("$HOME/.var/app/com.valvesoftware.Steam/.steam/root"),
        restic_exc("$HOME/.Private"),
        restic_exc("$HOME/.gvfs"),
        restic_exc("$HOME/.ccache"),
        restic_exc("$CACHEDIR/deja-dup", false),
        restic_exc("$HOME/.cache"),
        restic_exc("$CACHEDIR", false),
      };
      string[] excludes_from_includes = {};
      foreach (var inc in includes)
        excludes_from_includes += restic_exc(inc, false);
      foreach (var inc in extra_includes)
        excludes_from_includes += restic_exc(inc, false);

      List<string> exclude_args = null;

      exclude_args.concat(restic_exc_list(excludes_from_includes, false, true));
      exclude_args.concat(restic_exc_list(extra_excludes));
      exclude_args.concat(restic_exc_list(default_excludes));
      exclude_args.concat(restic_exc_list(builtin_excludes));
      exclude_args.concat(restic_exc_list(sym_target_excludes));
      exclude_args.sort(restic_path_cmp);

      args.concat(restic_exc_list(regex_excludes, false, false, false));
      args.concat(exclude_args.copy_deep(strdup));

      break;

    case "forget":
      args.append("forget");
      args.append("--host=%s".printf(host));
      args.append("--tag=deja-dup");
      args.append("--group-by=host,tags");
      args.append("--prune");
      if (keep_last >= 0)
        args.append("--keep-last=%d".printf(keep_last));
      if (keep_within >= 0)
        args.append("--keep-within=%dd".printf(keep_within));
      break;

    case "init":
      args.append("init");
      args.append("--repository-version=2");
      break;

    case "prune":
      args.append("prune");
      break;

    case "restore":
      var target = file_to_restore == null ? "/" : parse_path(file_to_restore);
      args.append("restore");
      args.append("--sparse");
      args.append(snapshot + ":" + Path.get_dirname(target));
      args.append("--include=/" + restic_escape(Path.get_basename(target)));
      args.append("--target=" + Path.build_filename(test_home, "restore"));
      break;

    case "status":
      args.append("snapshots");
      args.append("--host=%s".printf(host));
      args.append("--tag=deja-dup");
      break;

    case "unlock":
      args.append("unlock");
      break;

    case "verify":
      args.append("check");
      args.append("--with-cache");
      break;

    case "verify-clean":
      args.append("check");
      args.append("--read-data-subset=64M");
      args.append("--no-cache");
      break;

    default:
      warning("Got unexpected mode '%s'", mode);
      assert_not_reached();
  }

  string command = args.data;
  foreach(var arg in args.next) {
    command += " " + arg;
  }
  return command;
}

void process_restic_run_block(KeyFile keyfile, string run, BackupRunner br) throws Error
{
  string[] excludes = null;
  string[] sym_target_excludes = null;
  string[] includes = null;
  string file_to_restore = null;
  int keep_last = -1;
  int keep_within = -1;
  bool passphrase = false;
  string output = null;
  int return_code = 0;
  string script = null;
  string snapshot = null;
  bool stop = false;

  var parts = run.split(" ", 2);
  var mode = parts[0];
  var group = "Restic " + run;

  if (keyfile.has_group(group)) {
    if (keyfile.has_key(group, "ExtraExcludes"))
      excludes = get_string_list(keyfile, group, "ExtraExcludes");
    if (keyfile.has_key(group, "ExtraIncludes"))
      includes = get_string_list(keyfile, group, "ExtraIncludes");
    if (keyfile.has_key(group, "FileToRestore"))
      file_to_restore = get_string_field(keyfile, group, "FileToRestore");
    if (keyfile.has_key(group, "KeepLast"))
      keep_last = keyfile.get_integer(group, "KeepLast");
    if (keyfile.has_key(group, "KeepWithin"))
      keep_within = keyfile.get_integer(group, "KeepWithin");
    if (keyfile.has_key(group, "Passphrase"))
      passphrase = keyfile.get_boolean(group, "Passphrase");
    if (keyfile.has_key(group, "Output"))
      output = get_string_field(keyfile, group, "Output");
    if (keyfile.has_key(group, "Script"))
      script = get_string_field(keyfile, group, "Script");
    if (keyfile.has_key(group, "Return"))
      return_code = keyfile.get_integer(group, "Return");
    if (keyfile.has_key(group, "Stop"))
      stop = keyfile.get_boolean(group, "Stop");
    if (keyfile.has_key(group, "SymlinkTargetExcludes"))
      sym_target_excludes = get_string_list(keyfile, group, "SymlinkTargetExcludes");
    if (keyfile.has_key(group, "Snapshot"))
      snapshot = get_string_field(keyfile, group, "Snapshot");
  }

  var dupscript = "ARGS: " + restic_args(br, mode, excludes, sym_target_excludes,
                                         includes, keep_last, keep_within,
                                         file_to_restore, snapshot, !passphrase);

  if (stop) {
    dupscript += "\n" + "DELAY: 10";
    br.callback = (op) => {
      op.stop();
    };
  }

  if (return_code != 0)
    dupscript += "\n" + "RETURN: %d".printf(return_code);

  if (script != null) {
    dupscript += "\n" + "SCRIPT: " + script;
  }

  dupscript += "\n" + "PASSPHRASE:";
  if (passphrase) {
    dupscript += " test";
  }

  if (output != null && output != "") {
    // GLib prior to 2.59 added an extra \n to output, but we need \n\n
    // here, so we add them ourselves.
    dupscript += "\n\n" + output.chomp() + "\n\n";
  }

  add_to_mockscript(dupscript);
}

void process_restic_block(KeyFile keyfile, string group, BackupRunner br) throws Error
{
  var version = "9.9.99";
  if (keyfile.has_key(group, "Version"))
    version = get_string_field(keyfile, group, "Version");
  add_to_mockscript("ARGS: version --json\n\nrestic %s compiled with go1.15.8 on linux/amd64\n".printf(version));

  if (keyfile.has_key(group, "Error"))
    br.error_str = keyfile.get_string(group, "Error");

  if (keyfile.has_key(group, "Runs")) {
    var runs = keyfile.get_string_list(group, "Runs");
    foreach (var run in runs)
      process_restic_run_block(keyfile, run, br);
  }
}

void restic_run()
{
  var settings = DejaDup.get_settings();
  settings.set_string(DejaDup.TOOL_WHEN_NEW_KEY, "restic");

  try {
    var keyfile = load_script();
    var br = new BackupRunner();

    var groups = keyfile.get_groups();
    foreach (var group in groups) {
      if (group == "Operation")
        process_operation_block(keyfile, group, br);
      else if (group == "Restic" && br.op != null)
        process_restic_block(keyfile, group, br);
    }

    br.run();
  }
  catch (Error e) {
    warning("%s\n", e.message);
    assert_not_reached();
  }
}

const OptionEntry[] OPTIONS = {
  {null}
};

int main(string[] args)
{
  Test.init(ref args);

  OptionContext context = new OptionContext("");
  context.add_main_entries(OPTIONS, null);
  try {
    context.parse(ref args);
  } catch (Error e) {
    printerr("%s\n\n%s", e.message, context.get_help(true, null));
    return 1;
  }

  // Write all files inside a fake temporary dir in $HOME,
  // so that we don't trigger any container errors about /tmp
  // not being accessible.
  var root = Path.build_filename(Environment.get_variable("HOME"), ".tmp");
  Environment.set_variable("TMPDIR", root, true);
  DirUtils.create_with_parents(root, 0700);

  try {
    var dir = DirUtils.make_tmp("deja-dup-test-XXXXXX");
    Environment.set_variable("DEJA_DUP_TEST_HOME", dir, true);
  } catch (Error e) {
    printerr("Could not make temporary dir\n");
    return 1;
  }

  Environment.set_variable("DEJA_DUP_DEBUG", "1", true);
  Environment.set_variable("DEJA_DUP_LANGUAGE", "en", true);
  Test.bug_base("https://gitlab.gnome.org/World/deja-dup/-/issues/%s");

  var script = "unknown/unknown";
  if (args.length > 1)
    script = args[1];
  Environment.set_variable("DEJA_DUP_TEST_SCRIPT", script, true);

  // Save PATH, as tests might reset it on us
  Environment.set_variable("DEJA_DUP_TEST_PATH",
                           Environment.get_variable("PATH"), true);

  var parts = script.split("/");
  var testname = parts[parts.length - 1].split(".")[0];
  var keyfile = load_script();
  var found_group = false;

  if (keyfile.has_group("Duplicity")) {
    var suite = new TestSuite("duplicity");
    suite.add(new TestCase(testname, backup_setup, duplicity_run, backup_teardown));
    TestSuite.get_root().add_suite((owned)suite);
    found_group = true;
  }

  if (keyfile.has_group("Restic")) {
    var suite = new TestSuite("restic");
    suite.add(new TestCase(testname, backup_setup, restic_run, backup_teardown));
    TestSuite.get_root().add_suite((owned)suite);
    found_group = true;
  }

  if (!found_group)
    assert_not_reached();

  return Test.run();
}
