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

using GLib;

/**
 * FIXME:
 * - removing old versions of duplicity as we make restic ones (wait until out of beta?)
 * - add README.txt to storage location
 * - look at fixmes in this file and ResticInstance.vala
 * - look at restic bugs in gitlab
 */

internal class ResticJoblet : DejaDup.ToolJoblet
{
  protected bool ignore_errors = false;
  string rclone_remote = null;
  string tmpdir = null;
  string location_pretty = null;

  // Joblet interface
  protected override ToolInstance make_instance() {
    var instance = new ResticInstance();
    add_handler(instance.message.connect(handle_message));
    add_handler(instance.fatal_error.connect(handle_fatal_error));
    return instance;
  }

  protected override async void prepare() throws Error
  {
    if (tag == "")
      tag = "latest";

    yield base.prepare();
    // Cache this while we are in an async function, so we can use it
    // later in sync functions.
    location_pretty = yield backend.get_location_pretty();
    tmpdir = yield DejaDup.get_tempdir(); // save and use in prepare_args
  }

  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    argv.append(ResticPlugin.restic_command());
    argv.append("--json");
    argv.append("--cleanup-cache");

    var cachedir = restic_cachedir();
    if (cachedir != null)
      argv.append("--cache-dir=" + cachedir);

    if (encrypt_password == null || encrypt_password == "") {
      argv.append("--insecure-no-password");
      envp.append("RESTIC_PASSWORD=");
    }
    else
      envp.append("RESTIC_PASSWORD=" + encrypt_password);

    // Fill envp with rclone config if needed
    rclone_remote = Rclone.fill_envp_from_backend(backend, ref envp);
    if (rclone_remote != null)
      argv.append("--option=rclone.program=" + Rclone.rclone_command());

    argv.append(get_remote());

    if (DejaDup.ensure_directory_exists(tmpdir))
      envp.append("TMPDIR=%s".printf(tmpdir));
  }

  // Restic helpers
  protected virtual bool process_message(string? msgid, Json.Reader reader) { return false; }

  protected string escape_pattern(string path)
  {
    // https://restic.readthedocs.io/en/latest/040_backup.html#excluding-files
    return path.replace("$", "$$");
  }

  protected string escape_path(string path)
  {
    // https://golang.org/pkg/path/filepath/#Match
    var escaped = path.replace("\\", "\\\\");
    escaped = escaped.replace("*", "\\*");
    escaped = escaped.replace("?", "\\?");
    escaped = escaped.replace("[", "\\[");
    return escape_pattern(escaped);
  }

  // This will treat a < b if a is 'higher' in the file tree than b
  // (ignoring any leading negation '!' symbol, and sorting '*' before all)
  protected static int cmp_prefix(string a, string b)
  {
    string a_copy = null;
    string b_copy = null;
    unowned string a_cmp = a;
    unowned string b_cmp = b;
    if (a_cmp[0] == '!')
      a_cmp = a_cmp.offset(1);
    if (b_cmp[0] == '!')
      b_cmp = b_cmp.offset(1);

    // Sort wildcard endings earlier than actual content (otherwise paths
    // that start with low chars like '%' would appear before the wildcard
    // and not actually un-exclude themselves as needed).
    if (a_cmp.has_suffix("/*")) {
      a_copy = a_cmp.substring(0, a_cmp.length - 1);
      a_cmp = a_copy;
    }
    if (b_cmp.has_suffix("/*")) {
      b_copy = b_cmp.substring(0, b_cmp.length - 1);
      b_cmp = b_copy;
    }

    // Rough and ready version of testing for sharing a prefix is to just
    // do a string comparison - null sorts lower than non-null.
    return strcmp(a_cmp, b_cmp);
  }

  protected virtual void handle_no_repository() {}
  protected virtual bool handle_out_of_space() {return false;}

  protected override void handle_done(bool success, bool cancelled)
  {
    if (ignore_errors)
      success = true;
    base.handle_done(success, cancelled);
  }

  protected virtual void handle_fatal_error(int code, string msg)
  {
    switch (code)
    {
      case ResticInstance.ERROR_NO_REPOSITORY:
        handle_no_repository();
        return;
      case ResticInstance.ERROR_BAD_PASSWORD:
        bad_encryption_password();
        return;
      case ResticInstance.ERROR_OUT_OF_SPACE:
        if (handle_out_of_space())
          return;
        break;
    }

    if (ignore_errors)
      return;

    // Because we use the gvfs fuse path instead of the URI,
    // error messages might be extra confusing - replace the
    // strings to hide our nitty gritty details.
    var file_backend = backend as DejaDup.BackendFile;
    if (file_backend != null) {
      show_error(file_backend.replace_path_with_uri(msg));
    }
    else {
      show_error(msg);
    }
  }

  // Private helpers

  string get_remote() throws Error
  {
    string repo = null;

    var file_backend = backend as DejaDup.BackendFile;
    if (file_backend != null) {
      var file = file_backend.get_file_from_settings();
      if (file != null)
        repo = file.get_path();
    }

    if (rclone_remote != null) {
      repo = "rclone:" + rclone_remote;
    }

    if (repo == null)
      throw new IOError.NOT_FOUND(_("Could not find storage location ‘%s’.").printf(location_pretty));

    return "--repo=" + repo;
  }

  void handle_message(string? msgid, Json.Reader reader)
  {
    process_message(msgid, reader);
  }

  string? restic_cachedir()
  {
    string dir = Environment.get_user_cache_dir();
    if (dir == null)
      return null;
    return Path.build_filename(dir, Config.PACKAGE, "restic");
  }

  // We keep this property on the chain, across joblets.
  protected uint chain_get_num_snapshots()
  {
    return uint.from_pointer(chain.get_data("num-snapshots"));
  }
  protected void chain_set_num_snapshots(uint num)
  {
    chain.set_data("num-snapshots", num.to_pointer());
  }
}

internal class ResticInitJoblet : ResticJoblet
{
  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    base.prepare_args(ref argv, ref envp);
    argv.append("init");
    argv.append("--repository-version=2");
  }
}

// Jobs may leave stale locks if interrupted (e.g. an exclusive-lock action
// like pruning). If we try to do anything while an exclusive lock exists,
// restic will complain. There is slight risk here if there is maybe... a
// different laptop that is merely suspended also pointing at this same repo?
// But in general, we do not encourage re-using repos. And the actual harm of
// interrupted jobs is much more likely. (Note that this only clears stale
// locks.)
internal class ResticUnlockJoblet : ResticJoblet
{
  construct {
    // this is a best-effort joblet - we don't care if there is no repository, etc
    ignore_errors = true;
  }

  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    base.prepare_args(ref argv, ref envp);
    argv.append("unlock");
  }
}

internal class ResticBackupJoblet : ResticJoblet
{
  bool snapshot_made = false;
  int64 seconds_elapsed = -1;
  uint64 free_space = DejaDup.Backend.INFINITE_SPACE;
  uint64 total_space = DejaDup.Backend.INFINITE_SPACE;

  protected override void handle_done(bool success, bool cancelled)
  {
    // If we made a snapshot, still say it was a success. Restic might give a
    // non-zero return status if there were warnings about file I/O errors.
    if (snapshot_made && !cancelled)
      success = true;

    base.handle_done(success, cancelled);
  }

  protected override bool handle_out_of_space()
  {
    if (chain_get_num_snapshots() > 1) {
      chain.prepend_to_chain(new ResticBackupJoblet()); // 2nd
      chain.prepend_to_chain(new ResticDeleteOldestBackupJoblet()); // 1st
      ignore_errors = true; // ignore the non-zero exit status
      return true;
    }
    return false;
  }

  protected override async void prepare() throws Error
  {
    yield base.prepare();

    // grab backend space info - we will compare against size of sources
    yield backend.get_space(out free_space, out total_space);
  }

  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    base.prepare_args(ref argv, ref envp);
    tag = "latest"; // for the restore check's benefit, at end of backup

    argv.append("backup");
    argv.append("--host=" + Environment.get_host_name());
    argv.append("--tag=deja-dup");
    argv.append("--group-by=host,tags");
    argv.append("--exclude-caches");
    argv.append("--exclude-if-present=.deja-dup-ignore");
    add_include_excludes(ref argv);
  }

  bool process_status(Json.Reader reader)
  {
    // Read and save the seconds elapsed
    var current_seconds = seconds_elapsed;
    if (reader.read_member("seconds_elapsed"))
      seconds_elapsed = reader.get_int_value();
    else
      seconds_elapsed = 0;
    reader.end_member();

    // Throttle ourselves from updating the UI too often.
    // (arguably should be done in UI layer)
    if (current_seconds == seconds_elapsed)
      return true;

    // Check the total size
    reader.read_member("total_bytes");
    var total_bytes = reader.get_int_value();
    reader.end_member();
    if (total_bytes > total_space) {
      // Tiny backup location.  Suggest they get a larger one.
      var msg = "%s %s".printf(
        _("Backup location is too small."),
        _("Try using a location with at least %s.").printf(format_size(total_bytes))
      );
      show_error(msg);
      disconnect_inst();
      done(false, false);
      return true;
    }

    // Read the percent
    reader.read_member("percent_done");
    var percent_done = reader.get_double_value();
    reader.end_member();
    progress(percent_done);

    // Read an optional filename
    if (reader.read_member("current_files")) { // array of strings
      var count = reader.count_elements();
      if (count > 0) {
        reader.read_element(0); // only bother with looking at the first file
        var path = reader.get_string_value();
        reader.end_element();
        action_file_changed(File.new_for_path(path), true);
      }
    }
    reader.end_member();

    return true;
  }

  bool process_error(Json.Reader reader)
  {
    reader.read_member("item");
    var item = reader.get_string_value();
    reader.end_member();

    if (item != null && item != "")
      local_file_error(item);

    return true;
  }

  bool process_summary(Json.Reader reader)
  {
    reader.read_member("snapshot_id");
    var snapshot_id = reader.get_string_value();
    reader.end_member();

    if (snapshot_id != null && snapshot_id != "")
      snapshot_made = true;

    return true;
  }

  protected override bool process_message(string? msgid, Json.Reader reader)
  {
    if (msgid == "error")
      return process_error(reader);
    if (msgid == "status")
      return process_status(reader);
    if (msgid == "summary")
      return process_summary(reader);

    return false;
  }

  protected override void handle_no_repository()
  {
    // Notify upper layers that we have a full backup to do
    is_full(true);

    chain.prepend_to_chain(new ResticBackupJoblet()); // 2nd
    chain.prepend_to_chain(new ResticInitJoblet()); // 1st

    disconnect_inst();
    done(true, false);
  }

  bool list_contains_file(List<File> list, File file)
  {
    foreach (var f in list) {
      if (f.equal(file))
        return true;
    }
    return false;
  }

  void add_include_excludes(ref List<string> argv)
  {
    // Expand symlinks and ignore missing targets.
    // Restic will back up a symlink if specified directly. But if we left
    // symlinks in parent paths, it will treat them as normal directories.
    // These calls make sure we include the symlink and the target dirs.
    // (Which is consistent behavior with our Duplicity tool support.)
    DejaDup.expand_links_in_list(ref includes, true);
    DejaDup.expand_links_in_list(ref excludes, false);

    var all_exclude_paths = new List<string>();

    // To handle nested includes & excludes, we do some extra work to exclude
    // subdirs and un-exclude nested includes.
    // See https://github.com/restic/restic/issues/3408 for more context.

    foreach (var file in excludes) {
      // Avoid duplicating an exclude and an include - restic will choose exclude
      if (!list_contains_file(includes, file))
      {
        // Exclude both the dir and the dir contents - which lets us add back
        // in child contents later, in a nested include/exclude situation.
        all_exclude_paths.append(escape_path(file.get_path()));
        all_exclude_paths.append(escape_path(file.get_path()) + "/*");
      }
    }
    foreach (var file in includes) {
      // Un-exclude it first
      all_exclude_paths.append("!" + escape_path(file.get_path()));
      argv.append(file.get_path()); // doesn't need escaping
    }

    // Now sort all the excludes and add them in
    all_exclude_paths.sort(cmp_prefix);
    foreach (var exclude_path in all_exclude_paths) {
      argv.append("--exclude=" + exclude_path);
    }

    // End with regexes, to avoid being overridden by the un-exclusions using !
    foreach (var regexp in exclude_regexps) {
      argv.append("--exclude=" + escape_pattern(regexp));
    }
  }
}

internal class ResticDeleteJobletBase : ResticJoblet
{
  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    base.prepare_args(ref argv, ref envp);
    argv.append("forget");
    argv.append("--host=" + Environment.get_host_name());
    argv.append("--tag=deja-dup");
    argv.append("--group-by=host,tags");
    argv.append("--prune");
  }

  protected override async void prepare() throws Error
  {
    yield base.prepare();

    // Because "forget" is an exclusive-lock operation, try to unmount if we
    // can, to clear the way before we attempt to forget.
    var manager = DejaDup.MountManager.get_instance();
    var dir = manager.get_mount_dir(DejaDup.make_restic_tool(), backend);
    // don't worry about success or not, we'll solve that in handle_fatal_error
    manager.unmount(dir);
  }
}

internal class ResticDeleteOldBackupsJoblet : ResticDeleteJobletBase
{
  public int delete_after {get; construct;} // number of days

  public ResticDeleteOldBackupsJoblet(int delete_after) {
    Object(delete_after: delete_after);
  }

  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    base.prepare_args(ref argv, ref envp);
    argv.append("--keep-within=%dd".printf(delete_after));
  }

  protected override void handle_fatal_error(int code, string msg)
  {
    if (code == ResticInstance.ERROR_ALREADY_LOCKED) {
      // This is only called to support the DELETE_AFTER_KEY, which is a
      // best-effort feature - if the repo is in active use (like if we have an
      // open mount), we can wait til the next backup to clean up old snapshots.
      ignore_errors = true;
    }

    base.handle_fatal_error(code, msg);
  }
}

// Only works if you've done a ListSnapshotsJoblet first (for num-snapshots value)
internal class ResticDeleteOldestBackupJoblet : ResticDeleteJobletBase
{
  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    base.prepare_args(ref argv, ref envp);

    var num_snapshots = chain_get_num_snapshots();
    argv.append("--keep-last=%u".printf(num_snapshots - 1));
  }

  protected override void handle_done(bool success, bool cancelled)
  {
    if (success)
      chain_set_num_snapshots(chain_get_num_snapshots() - 1);

    base.handle_done(success, cancelled);
  }

  protected override void handle_fatal_error(int code, string msg)
  {
    var new_msg = msg;
    if (code == ResticInstance.ERROR_ALREADY_LOCKED)
      new_msg = _("The storage location is out of space but busy.") + " " +
                _("Try closing any open file manager windows and try again.");

    base.handle_fatal_error(code, new_msg);
  }
}

internal class ResticSnapshotsJoblet : ResticJoblet
{
  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    base.prepare_args(ref argv, ref envp);
    argv.append("snapshots");
    // When backing up, we only want to look for our own snapshots, because
    // we are checking how many snapshots we could delete if needed.
    // But when restoring, we want to allow any hostname (for new-machine
    // purposes and to allow using the same storage folder on multiple
    // machines). We also don't require a deja-dup tag, to allow restoring
    // a non-Deja-Dup backup.
    if (mode == Mode.BACKUP) {
      argv.append("--host=" + Environment.get_host_name());
      argv.append("--tag=deja-dup");
    }
  }

  protected override void handle_no_repository()
  {
    collection_dates(new List<DejaDup.SnapshotInfo>());
    chain_set_num_snapshots(0);
    ignore_errors = true;
  }

  protected override bool process_message(string? msgid, Json.Reader reader)
  {
    if (msgid == null)
      return process_snapshots(reader);

    return false;
  }

  bool process_snapshots(Json.Reader reader)
  {
    var dates = new List<DejaDup.SnapshotInfo>();
    var this_host = Environment.get_host_name();

    for (int i = 0; i < reader.count_elements(); i++) {
      reader.read_element(i);

      reader.read_member("id");
      var tag = reader.get_string_value();
      reader.end_member();

      reader.read_member("time");
      var strtime = reader.get_string_value();
      reader.end_member();

      reader.read_member("hostname");
      var hostname = reader.get_string_value();
      reader.end_member();

      // Check if we have a "deja-dup" tag
      var is_external = true;
      if (reader.read_member("tags")) {
        for (int j = 0; j < reader.count_elements(); j++) {
          reader.read_element(j);
          var label = reader.get_string_value();
          reader.end_element();
          if (label == "deja-dup") {
            is_external = false;
            break;
          }
        }
      }
      reader.end_member();

      string[] keywords = {};
      if (hostname != this_host)
        keywords += hostname;
      if (is_external)
        // Translators: "external" here means a backup that Deja Dup didn't make
        keywords += C_("non-Deja-Dup backup", "external");

      var time = new DateTime.from_iso8601(strtime, new TimeZone.utc());
      var info = new DejaDup.SnapshotInfo(time, tag);
      info.external_description = string.joinv(", ", keywords);
      dates.prepend(info);

      reader.end_element();
    }

    collection_dates(dates);
    chain_set_num_snapshots(dates.length());
    return true;
  }
}

internal class ResticMountJoblet : ResticJoblet
{
  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    base.prepare_args(ref argv, ref envp);
    argv.append("mount");
    argv.append("--path-template=ids/%I"); // use long IDs, to match our tags

    var manager = DejaDup.MountManager.get_instance();
    var dir = manager.get_mount_dir(DejaDup.make_restic_tool(), backend);
    argv.append(dir);
  }
}

internal class ResticListJoblet : ResticJoblet
{
  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    base.prepare_args(ref argv, ref envp);
    argv.append("ls");
    argv.append(tag);

    if (tag == "latest") {
      argv.append("--host=" + Environment.get_host_name());
      argv.append("--tag=deja-dup");
    }
  }

  protected override bool process_message(string? msgid, Json.Reader reader)
  {
    if (msgid == "node")
      return process_node(reader);

    return false;
  }

  bool process_node(Json.Reader reader)
  {
    reader.read_member("type");
    var restic_type = reader.get_string_value();
    reader.end_member();

    if (restic_type == null)
      return false; // might be initial snapshot struct_type message

    reader.read_member("path");
    var path = reader.get_string_value();
    reader.end_member();

    var file_type = FileType.UNKNOWN;
    if (restic_type == "file")
      file_type = FileType.REGULAR;
    else if (restic_type == "dir")
      file_type = FileType.DIRECTORY;
    else if (restic_type == "symlink")
      file_type = FileType.SYMBOLIC_LINK;

    listed_current_files(path, file_type);
    return true;
  }
}

// Only handles one file at a time, given in constructor
internal class ResticRestoreJoblet : ResticJoblet
{
  public File restore_file {get; construct;}
  public double percentage {get; construct;}
  public ResticRestoreJoblet(File? restore_file, double percentage)
  {
    Object(restore_file: restore_file, percentage: percentage);
  }

  bool fatal_error_issued = false;

  protected override void handle_fatal_error(int code, string msg)
  {
    fatal_error_issued = true;
    base.handle_fatal_error(code, msg);
  }

  protected override void handle_done(bool success, bool cancelled)
  {
    // Restic returns a 1 exit status without an exit_error message
    // if there were any internal per-file errors. But we prefer to
    // treat that as a success.
    if (!fatal_error_issued && !success)
      success = true;

    if (success)
      progress(percentage);

    base.handle_done(success, cancelled);
  }

  bool process_error(Json.Reader reader)
  {
    reader.read_member("item");
    var item = reader.get_string_value();
    reader.end_member();

    if (item != null && item != "") {
      local_file_error(item);
    }

    return true;
  }

  protected override bool process_message(string? msgid, Json.Reader reader)
  {
    if (msgid == "error")
      return process_error(reader);

    return false;
  }

  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    var local_target = restore_file == null ? "/" : restore_file.get_path();
    var remote_target = tree.original_path(local_target);
    var remote_target_dir = Path.get_dirname(remote_target);
    var remote_target_file = Path.get_basename(remote_target);

    base.prepare_args(ref argv, ref envp);

    argv.append("restore");
    argv.append("--sparse");
    // By specifying a target dir, we avoid the full abs path being placed
    argv.append("%s:%s".printf(tag, remote_target_dir));
    // Anchor the include pattern with / so only match at toplevel
    argv.append("--include=/" + escape_path(remote_target_file));

    if (tag == "latest") {
      argv.append("--host=" + Environment.get_host_name());
      argv.append("--tag=deja-dup");
    }

    if (local.get_parent() == null) // original location
      argv.append("--target=" + Path.get_dirname(local_target));
    else
      argv.append("--target=" + local.get_path());
  }
}

internal class ResticCheckJoblet : ResticJoblet
{
  public bool read_data {get; construct;}
  public ResticCheckJoblet(bool read_data)
  {
    Object(read_data: read_data);
  }

  protected override void prepare_args(ref List<string> argv, ref List<string> envp) throws Error
  {
    base.prepare_args(ref argv, ref envp);

    argv.append("check");

    if (read_data) {
      // 64M is arbitrary - enough to test four "packs" (with the default pack
      // size of 16MiB), but still respectful of bandwidth.
      argv.append("--read-data-subset=64M");
      argv.append("--no-cache");
    } else {
      // Read as little data as possible
      argv.append("--with-cache");
    }
  }

  protected override void handle_fatal_error(int code, string msg)
  {
    if (code == ResticInstance.ERROR_ALREADY_LOCKED) {
      // Some other restic job is running - either the user's own run or our
      // own mount job. In either case, 'check' is optional and can wait.
      ignore_errors = true;
    }

    var new_msg = make_verification_error_message(msg);
    base.handle_fatal_error(code, new_msg);
  }
}

internal class ResticJob : DejaDup.ToolJobChain
{
  public override async void start()
  {
    action_desc_changed(DejaDup.Operation.mode_to_string(mode));

    switch (mode) {
    case Mode.BACKUP:
      var settings = DejaDup.get_settings();
      var delete_after = settings.get_int(DejaDup.DELETE_AFTER_KEY);

      append_to_chain(new ResticUnlockJoblet());
      // only need snapshots call if we run out of space, but it's quick to do always
      append_to_chain(new ResticSnapshotsJoblet());
      append_to_chain(new ResticBackupJoblet());
      if (delete_after > 0)
        append_to_chain(new ResticDeleteOldBackupsJoblet(delete_after));

      break;
    case Mode.RESTORE:
      append_to_chain(new ResticUnlockJoblet());
      if (restore_files == null) {
        append_to_chain(new ResticRestoreJoblet(null, 0));
      }
      else {
        double i = 0;
        foreach (var file in restore_files) {
          append_to_chain(new ResticRestoreJoblet(file, i++ / restore_files.length()));
        }
      }
      break;
    case Mode.LIST_SNAPSHOTS:
      append_to_chain(new ResticUnlockJoblet());
      append_to_chain(new ResticSnapshotsJoblet());
      break;
    case Mode.LIST_FILES:
      append_to_chain(new ResticUnlockJoblet());
      append_to_chain(new ResticListJoblet());
      break;
    case Mode.VERIFY_BASIC:
      append_to_chain(new ResticCheckJoblet(false));
      break;
    case Mode.VERIFY_CLEAN:
      append_to_chain(new ResticUnlockJoblet());
      append_to_chain(new ResticCheckJoblet(true));
      break;
    case Mode.MOUNT:
      append_to_chain(new ResticUnlockJoblet());
      append_to_chain(new ResticMountJoblet());
      break;
    default:
      warning("Unknown mode %d", mode);
      done(true, false);
      return;
    }

    yield base.start();
  }
}
