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

using GLib;

namespace DejaDup {

public abstract class Operation : Object
{
  /**
   * Abstract class that abstracts low level operations of duplicity
   * with specific classes for specific operations
   *
   * Abstract class that defines methods and properties that have to be defined
   * by classes that abstract operations from duplicity. It is generally unnecessary
   * but it is provided to provide easier development and an abstraction layer
   * in case Deja Dup project ever replaces its backend.
   */

  public signal void started();

  // success is true for normal successful finish or stop()
  // cancelled is true for stop()
  public signal void done(bool success, bool cancelled, string? detail);

  public signal void raise_error(string errstr, string? detail);
  public signal void action_desc_changed(string action);
  public signal void action_file_changed(File file, bool actual);
  public signal void is_full(bool first);
  public signal void progress(double percent);
  public signal void open_url(string url);
  public signal void mount_op_required(); // paired with set_mount_op()
  public signal void passphrase_required(bool repeat, bool first); // paired with set_passphrase()
  public signal void backend_password_required(string desc, string label, string? error); // paired with set_backend_password()
  public signal void pause_changed(bool paused);

  // Question() is a signal to the UI to interact with the user, with a simple
  // cancel/continue dialog. It can't ask complicated questions with more than
  // once answer yet, just because we haven't needed it. Examples of questions
  // are "the last backup to this folder had a different hostname, you sure?"
  // or "to continue, you need to install these packages, you good?".
  // Pair it with resume() or close(). Markup may be in the description.
  public signal void question(
    string summary, string? header, string? description, string? action, bool safe
  );

  public bool use_cached_password {get; protected set; default = true;}
  public bool needs_password {get; set;}
  public Backend backend {get; protected set;}
  public bool use_progress {get; set; default = true;}

  public ToolJob.Mode mode {get; protected set; default = ToolJob.Mode.INVALID;}

  public static string mode_to_string(ToolJob.Mode mode)
  {
    switch (mode) {
    case ToolJob.Mode.BACKUP:
      return _("Backing up…");
    case ToolJob.Mode.RESTORE:
      return _("Restoring…");
    case ToolJob.Mode.LIST_SNAPSHOTS:
      return _("Checking for backups…");
    case ToolJob.Mode.LIST_FILES:
      return _("Listing files…");
    case ToolJob.Mode.VERIFY_BASIC:
    case ToolJob.Mode.VERIFY_CLEAN:
      return _("Verifying backup…");
    default:
      return _("Preparing…");
    }
  }

  // The State functions can be used to carry information from one operation
  // to another.
  public class State {
    public Backend backend;
    public ToolPlugin tool;
    public string passphrase;
  }
  public virtual State get_state() {
    var rv = new State();
    rv.backend = backend;
    rv.tool = tool;
    rv.passphrase = passphrase;
    return rv;
  }
  public virtual void set_state(State state) {
    backend = state.backend;
    tool = state.tool;
    set_passphrase_fields(state.passphrase);
  }

  internal ToolPlugin tool;
  internal ToolJob job;
  protected string passphrase;
  bool initial_setup_done = false;
  bool first_passphrase_selected = false;
  bool finished = false;
  protected Operation chained_op = null;
  bool searched_for_passphrase = false;
  GenericSet<string?> local_error_files = new GenericSet<string?>(str_hash, str_equal);
  Variant? job_question_state;
  string[] install_package_ids;
  string oauth_url_to_open;

  ~Operation()
  {
    debug("Finalizing Operation\n");
  }

  public async virtual void start()
  {
    started();
    yield restart();
  }

  async bool do_initial_setup()
  {
    if (initial_setup_done)
      return true;

    if (yield check_backend_dependencies())
      return false;

    if (!yield make_tool())
      return false;
    if (finished)
      return false;

    if (yield check_tool_dependencies())
      return false;

    string stdout, stderr;
    if (!run_custom_tool_command(DejaDup.CUSTOM_TOOL_SETUP_KEY, out stdout, out stderr)) {
      var detail = (stdout + stderr).strip();
      if (detail == "")
        detail = null;
      raise_error(_("Custom tool setup failed."), detail);
      send_done(false, false);
      return false;
    }

    initial_setup_done = true;
    return true;
  }

  async void restart()
  {
    if (chained_op != null) {
      yield chained_op.restart();
      return;
    }

    // Handle some lingering question() actions that the user must have approved:

    // PackageKit installation
    if (install_package_ids != null) {
      yield install_dependencies(install_package_ids);
      install_package_ids = null;
    }

    // OAuth authentication dance
    if (oauth_url_to_open != null) {
      open_url(oauth_url_to_open);
      return; // we will be resumed by a call to set_oauth_token()
    }

    // OK back to normal backup business.
    reset_progress();

    if (!initial_setup_done && !yield do_initial_setup())
      return;

    disconnect_job();

    string reason;
    if (!backend.is_acceptable(out reason)) {
      raise_error(reason, null);
      send_done(false, false);
      return;
    }

    string support_explanation;
    if (!tool.supports_backend(backend.kind, out support_explanation)) {
      raise_error(support_explanation, null);
      send_done(false, false);
      return;
    }

    // Get encryption passphrase if needed
    if (needs_password && passphrase == null) {
      yield find_or_ask_for_passphrase();
      return;
    }

    if (!yield prepare())
      return;

    try {
      job = tool.create_job();
    }
    catch (Error e) {
      raise_error(e.message, null);
      send_done(false, false);
      return;
    }

    job.encrypt_password = passphrase;
    job.mode = mode;
    job.backend = backend;
    if (job_question_state != null) {
      job.question_state = job_question_state;
      job_question_state = null;
    }
    if (!use_progress)
      job.flags |= ToolJob.Flags.NO_PROGRESS;

    make_argv();
    connect_to_job();

    yield job.start();
  }

  // "dormant" here means we aren't actively running a tool job.
  // i.e. we are waiting for user input or paused or something similar.
  public virtual bool stop_if_dormant()
  {
    if (chained_op != null)
      return chained_op.stop_if_dormant();
    else if (job == null) {
      stop();
      return true;
    }
    return false;
  }

  public virtual void stop()
  {
    if (chained_op != null)
      chained_op.stop();
    else if (job != null)
      job.stop();
    else
      operation_finished.begin(true, true);
  }

  public virtual void resume()
  {
    // We just came back from a question to the user, let's restart
    restart.begin();
  }

  protected virtual async bool prepare()
  {
    return true;
  }

  void reset_progress() // goes back to neutral statement and 0 progress
  {
    action_desc_changed(mode_to_string(mode));
    progress(0);
  }

  async void send_oauth_question(string msg, string header)
  {
    string desc = _("Once granted, Backups will automatically continue");

    if (!yield is_secret_service_available()) {
      desc = _("Note that you will have to grant access every time " +
               "because your desktop session does not support saving " +
               "credentials");
    }

    question(msg, header, desc, _("_Grant Access…"), true);
  }

  protected virtual void connect_to_backend()
  {
    backend.show_oauth_consent_page.connect((msg, header, url) => {
      oauth_url_to_open = url;
      send_oauth_question.begin(msg, header);
    });
    backend.pause_op.connect((header, msg) => {
      if (header == null && msg == null) { // "unpause"
        reset_progress();
        pause_changed(false);
      }
      else {
        action_desc_changed(msg); // ignore header TODO - is that right?
        progress(0);
        pause_changed(true);
      }
    });
    backend.show_backend_password_page.connect((d, l, e) => {
      backend_password_required(d, l, e);
    });
  }

  protected virtual void connect_to_job(bool actions = true)
  {
    /*
     * Connect Deja Dup to signals
     */
    job.done.connect((d, o, c) => {operation_finished.begin(o, c);});
    job.raise_error.connect((d, s, detail) => {raise_error(s, detail);});
    if (actions) {
      job.action_desc_changed.connect((d, s) => {action_desc_changed(s);});
      job.action_file_changed.connect((d, f, b) => {action_file_changed(f, b);});
    }
    job.local_file_error.connect(note_local_file_error);
    job.progress.connect((d, p) => {progress(p);});
    job.question.connect((s, h, d, a, safe, state) => {
      job_question_state = state;
      question(s, h, d, a, safe);
    });
    job.is_full.connect((first) => {is_full(first);});
    job.bad_encryption_password.connect(() => {
      // If tool gives us a gpg error, we set needs_password so that
      // we will prompt for it.
      needs_password = true;
      passphrase = null;
      restart.begin();
    });
  }

  protected void set_passphrase_fields(string? passphrase)
  {
    needs_password = false;

    if (chained_op != null) {
      chained_op.set_passphrase_fields(passphrase);
      return;
    }

    this.passphrase = passphrase;
    if (job != null)
      job.encrypt_password = passphrase;
  }

  public virtual async void set_passphrase(string? passphrase, bool save)
  {
    var processed = DejaDup.process_passphrase(passphrase);
    if (use_cached_password)
      yield DejaDup.store_passphrase(processed, save); // may clear value

    set_passphrase_fields(processed);
    yield restart();
  }

  public virtual async void set_backend_password(string? password, bool save)
  {
    var processed = DejaDup.process_passphrase(password);

    yield backend.provide_backend_password(processed, save);
  }

  public virtual void set_mount_op(MountOperation mount_op)
  {
    backend.mount_op = mount_op;
    restart.begin();
  }

  public virtual bool set_oauth_token(string uri)
  {
    // Do some basic validation to ensure we're in the right place.
    var oauth_backend = backend as BackendOAuth;
    if (oauth_backend == null)
      return false;

    // Normalize backend URI through gio, so it matches incoming URI format
    // (slashes after colon, etc)
    var redirect_uri = File.new_for_uri(oauth_backend.get_redirect_uri()).get_uri();
    if (!uri.has_prefix(redirect_uri))
      return false;

    oauth_url_to_open = null;
    reset_progress();
    oauth_backend.continue_authorization(uri);

    // You might think we'd call restart.begin() here, but actually, the
    // OAuth backend was paused and now will continue on its own.
    // This is maybe not the best async design, but it can be fixed later.

    return true;
  }

  // Returns any extra info (like specific files that didn't back up)
  protected virtual string? get_success_detail()
  {
    return null;
  }

  void send_done(bool success, bool cancelled)
  {
    string detail = null;
    if (success && !cancelled)
      detail = get_success_detail();

    done(success, cancelled, detail);
  }

  internal async virtual void operation_finished(bool success, bool cancelled)
  {
    finished = true;

    yield backend.cleanup();
    yield DejaDup.clean_tempdirs();
    run_custom_tool_command(DejaDup.CUSTOM_TOOL_TEARDOWN_KEY);

    send_done(success, cancelled);
  }

  protected virtual List<string>? make_argv()
  {
  /**
   * Abstract method that prepares arguments that will be sent to duplicity
   *
   * Abstract method that will prepare arguments that will be sent to duplicity
   * and return a list of those arguments.
   */
    return null;
  }

  void disconnect_job()
  {
    if (job == null)
      return;

    SignalHandler.disconnect_matched(job, SignalMatchType.DATA,
                                     0, 0, null, null, this);
    job.stop();
    job = null;
  }

  protected async void chain_op(Operation subop, string desc)
  {
    /**
     * Sometimes an operation wants to chain to a separate operation.
     * Here is the glue to make that happen.
     */
    assert(chained_op == null);

    chained_op = subop;
    subop.done.connect((s, c, d) => {
      // ignore detail from chained op for now - can fix if we need to
      send_done(s, c);
      chained_op = null;
    });
    subop.raise_error.connect((e, d) => {raise_error(e, d);});
    subop.action_desc_changed.connect((d) => {action_desc_changed(d);});
    subop.progress.connect((p) => {progress(p);});
    subop.passphrase_required.connect((r, f) => {
      needs_password = true;
      passphrase_required(r, f);
    });
    subop.question.connect((s, h, d, a, safe) => {question(s, h, d, a, safe);});

    use_cached_password = subop.use_cached_password;
    subop.set_state(get_state());

    action_desc_changed(desc);
    progress(0);

    yield subop.start();
  }

  async void find_or_ask_for_passphrase()
  {
    var already_searched = searched_for_passphrase;

    // If we get asked for passphrase again, it is because a
    // saved or entered passphrase didn't work.  So don't bother
    // searching a second time.
    searched_for_passphrase = true;

    // First, looks locally in keyring
    if (!already_searched && !DejaDup.in_testing_mode() && use_cached_password) {
      var str = yield DejaDup.lookup_passphrase();

      // Did we get anything?
      if (str != null) {
        set_passphrase_fields(str);
        yield restart();
        return;
      }
    }

    passphrase_required(already_searched, false); // tell upper layer
  }

  // Returns true if the command succeeded
  bool run_custom_tool_command(string key, out string stdout = null, out string stderr = null)
  {
    stdout = null;
    stderr = null;

    var settings = DejaDup.get_settings();
    var command = settings.get_string(key);
    if (command == "")
      return true;

    int status;
    try {
      debug("Running '%s'", command);
      Process.spawn_command_line_sync(command, out stdout, out stderr, out status);
    }
    catch (Error e) {
      stdout = e.message;
      stderr = "";
      return false;
    }

    // echo these to the console to help a user debugging their script
    print(stdout);
    print(stderr);

    return Process.if_exited(status) && Process.exit_status(status) == 0;
  }

  void note_local_file_error(string file)
  {
    local_error_files.add(file);
  }

  protected List<weak string?> get_local_error_files()
  {
    var sorted_error_files = local_error_files.get_values();
    sorted_error_files.sort(strcmp);
    return sorted_error_files;
  }

#if HAS_PACKAGEKIT
  async Pk.Results? get_pk_results(Pk.Client client, Pk.Bitfield bitfield, string[] pkgs)
  {
    Pk.Results results;
    try {
      results = yield client.resolve_async(bitfield, pkgs, null, () => {});
      if (results == null || results.get_error_code() != null)
        return null;
    } catch (IOError.NOT_FOUND e) {
      // This happens when the packagekit daemon isn't running -- it can't find the socket
      return null;
    } catch (Pk.ControlError e) {
      // This can happen when the packagekit daemon isn't installed or can't start(?)
      return null;
    } catch (Error e) {
      // For any other reason I can't foresee, we should just continue and
      // hope for the best, rather than bother the user with it.
      warning("%s\n".printf(e.message));
      return null;
    }

    return results;
  }
#endif

  async bool check_dependencies(string[] deps)
  {
#if HAS_PACKAGEKIT
    if (deps.length == 0)
      return false;

    var client = new Pk.Client();

    // Check which deps have any version installed
    var bitfield = Pk.Bitfield.from_enums(Pk.Filter.INSTALLED, Pk.Filter.ARCH);
    Pk.Results results = yield get_pk_results(client, bitfield, deps);
    if (results == null)
      return false;

    // Convert that to a set
    var installed = new GenericSet<string>(str_hash, str_equal);
    var package_array = results.get_package_array();
    for (var i = 0; i < package_array.length; i++) {
      installed.add(package_array.data[i].get_name());
    }

    // Now see which packages we actually have to bother installing
    string[] uninstalled = {};
    foreach (string pkg in deps) {
      if (!installed.contains(pkg))
        uninstalled += pkg;
    }
    if (uninstalled.length == 0)
      return false;

    // Now get the list of uninstalled (we do both passes, because if there is
    // an update for a package, the new version can be returned here, even if
    // there is an older version installed -- NEWEST or NOT_NEWEST does not
    // affect this behavior).
    bitfield = Pk.Bitfield.from_enums(Pk.Filter.NOT_INSTALLED, Pk.Filter.ARCH, Pk.Filter.NEWEST);
    results = yield get_pk_results(client, bitfield, uninstalled);
    if (results == null)
      return false;

    // Convert from List to arrays
    package_array = results.get_package_array();
    var package_ids = new string[0];
    var package_names = new string[0];
    for (var i = 0; i < package_array.length; i++) {
      package_names += package_array.data[i].get_name();
      package_ids += package_array.data[i].get_id();
    }

    if (package_names.length == 0)
      return false;

    install_package_ids = package_ids;

    var msg = _("In order to continue, the following packages need to be installed:");
    msg += " <b>" + string.joinv("</b>, <b>", package_names) + "</b>";

    question(
      _("Required packages need to be installed."),
      _("Required Packages"), msg, C_("verb", "_Install"), true
    );

    return true;
#else
    return false;
#endif
  }

  async bool check_backend_dependencies()
  {
    // Only check packagekit if we don't already have the deps available
    if (backend.needs_dependencies())
      return yield check_dependencies(backend.get_dependencies());
    return false;
  }

  async bool check_tool_dependencies()
  {
    // Only check packagekit if we don't already have the deps available
    if (tool.needs_dependencies())
      return yield check_dependencies(tool.get_dependencies());
    return false;
  }

  async void install_dependencies(string[] deps)
  {
#if HAS_PACKAGEKIT
    action_desc_changed(_("Installing packages…"));
    progress(0);

    var client = new Pk.Client();

    try {
      yield client.install_packages_async(0, deps, null, (p, t) => {
        progress((p.percentage / 100.0).clamp(0, 100));
      });
    }
    catch (Error e) {
      raise_error(e.message, null);
      return;
    }
#endif
  }

  async bool make_tool()
  {
    connect_to_backend();
    try {
      yield backend.prepare(); // will mount as needed

      tool = yield DejaDup.get_tool_for_backend(backend);

      // Detect initial backup case
      if (tool == null && !first_passphrase_selected && mode == ToolJob.Mode.BACKUP) {
        first_passphrase_selected = true;
        passphrase_required(false, true);
        return false;
      }

      if (tool == null)
        tool = DejaDup.get_default_tool();

      return true;
    }
    catch (BackendError.MOUNT_OP_NEEDED e) {
      mount_op_required();
      return false;
    }
    catch (Error e) {
      raise_error(e.message, null);
      send_done(false, false);
      return false;
    }
  }
}

} // end namespace

