/*
 * Copyright 2017 Richard Hughes <richard@hughsie.com>
 *
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */

#define G_LOG_DOMAIN "FuProgressBar"

#include "config.h"

#include <glib/gi18n.h>
#include <stdio.h>

#ifdef HAVE_READLINE
#include <readline/history.h>
#include <readline/readline.h>
#endif

#include "fu-console.h"

#ifdef _WIN32
#include <wchar.h>
#include <windows.h>
#endif

struct _FuConsole {
	GObject parent_instance;
	GMainContext *main_ctx;
	FwupdStatus status;
	gboolean spinner_count_up; /* width in visible chars */
	guint spinner_idx;	   /* width in visible chars */
	guint length_percentage;   /* width in visible chars */
	guint length_status;	   /* width in visible chars */
	guint percentage;
	GSource *timer_source;
	gint64 last_animated; /* monotonic */
	GTimer *time_elapsed;
	gdouble last_estimate;
	gboolean interactive;
	gboolean contents_to_clear;
};

G_DEFINE_TYPE(FuConsole, fu_console, G_TYPE_OBJECT)

gboolean
fu_console_setup(FuConsole *self, GError **error)
{
#ifdef _WIN32
	HANDLE out;
	DWORD mode = 0;

	/* enable VT sequences */
	out = GetStdHandle(STD_OUTPUT_HANDLE);
	if (out == INVALID_HANDLE_VALUE) {
		g_set_error(error,
			    FWUPD_ERROR,
			    FWUPD_ERROR_NOT_SUPPORTED,
			    "failed to get stdout [%u]",
			    (guint)GetLastError());
		return FALSE;
	}
	if (!GetConsoleMode(out, &mode)) {
		g_set_error(error,
			    FWUPD_ERROR,
			    FWUPD_ERROR_NOT_SUPPORTED,
			    "failed to get mode [%u]",
			    (guint)GetLastError());
		return FALSE;
	}
	mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
	if (!SetConsoleMode(out, mode)) {
		g_set_error(error,
			    FWUPD_ERROR,
			    FWUPD_ERROR_NOT_SUPPORTED,
			    "failed to set mode [%u]",
			    (guint)GetLastError());
		return FALSE;
	}
	if (!SetConsoleOutputCP(CP_UTF8)) {
		g_set_error(error,
			    FWUPD_ERROR,
			    FWUPD_ERROR_NOT_SUPPORTED,
			    "failed to set output UTF-8 [%u]",
			    (guint)GetLastError());
		return FALSE;
	}
	if (!SetConsoleCP(CP_UTF8)) {
		g_set_error(error,
			    FWUPD_ERROR,
			    FWUPD_ERROR_NOT_SUPPORTED,
			    "failed to set UTF-8 [%u]",
			    (guint)GetLastError());
		return FALSE;
	}
#else
	if (isatty(fileno(stdin)) == 0) {
		g_set_error_literal(error, FWUPD_ERROR, FWUPD_ERROR_NOT_SUPPORTED, "not a TTY");
		return FALSE;
	}
	if (isatty(fileno(stdout)) == 0) {
		g_set_error_literal(error, FWUPD_ERROR, FWUPD_ERROR_NOT_SUPPORTED, "not a TTY");
		return FALSE;
	}

#endif
	/* success */
	return TRUE;
}

static void
fu_console_erase_line(FuConsole *self)
{
	if (!self->interactive)
		return;
	g_print("\033[G");
}

static void
fu_console_reset_line(FuConsole *self)
{
	if (self->contents_to_clear) {
		fu_console_erase_line(self);
		g_print("\n");
		self->contents_to_clear = FALSE;
	}
}

void
fu_console_print_kv(FuConsole *self, const gchar *title, const gchar *msg)
{
	gsize title_len;
	g_auto(GStrv) lines = NULL;

	if (msg == NULL)
		return;
	fu_console_reset_line(self);
	g_print("%s:", title);

	/* pad */
	title_len = fu_strwidth(title) + 1;
	lines = g_strsplit(msg, "\n", -1);
	for (guint j = 0; lines[j] != NULL; j++) {
		for (gsize i = title_len; i < 25; i++)
			g_print(" ");
		g_print("%s\n", lines[j]);
		title_len = 0;
	}
}

#ifndef HAVE_READLINE
static gchar *
readline(const gchar *prompt) /* nocheck:name */
{
	char buffer[64] = {0};

	if (prompt != NULL)
		g_print("%s\n", prompt);
	if (!fgets(buffer, sizeof(buffer), stdin))
		return NULL;
	g_strdelimit(buffer, "\n", '\0');
	return g_strndup(buffer, sizeof(buffer));
}
#endif

guint
fu_console_input_uint(FuConsole *self, guint maxnum, const gchar *format, ...)
{
	gint retval;
	guint answer = 0;
	va_list args;
	g_autofree gchar *tmp = NULL;
	g_autofree gchar *prompt = NULL;

	va_start(args, format);
	tmp = g_strdup_vprintf(format, args);
	va_end(args);

	fu_console_print_full(self, FU_CONSOLE_PRINT_FLAG_NONE, "%s [0-%u]: ", tmp, maxnum);
	do {
		g_autofree gchar *buffer = readline(prompt);

		if (buffer == NULL)
			break;

		/* get a number */
		retval = sscanf(buffer, "%u", &answer);

		/* positive */
		if (retval == 1 && answer <= maxnum)
			break;

		if (prompt == NULL) {
			g_autoptr(GString) str = g_string_new(NULL);
			g_string_append_printf(
			    str,
			    /* TRANSLATORS: the user isn't reading the question */
			    _("Please enter a number from 0 to %u:"),
			    maxnum);
			g_string_append(str, " ");
			prompt = g_string_free(g_steal_pointer(&str), FALSE);
		}
	} while (TRUE);
	return answer;
}

gboolean
fu_console_input_bool(FuConsole *self, gboolean def, const gchar *format, ...)
{
	va_list args;
	g_autofree gchar *tmp = NULL;
	g_autofree gchar *prompt = NULL;

	va_start(args, format);
	tmp = g_strdup_vprintf(format, args);
	va_end(args);

	fu_console_print_full(self,
			      FU_CONSOLE_PRINT_FLAG_NONE,
			      "%s [%s]: ",
			      tmp,
			      def ? "Y|n" : "y|N");

	do {
		g_autofree gchar *buffer = readline(prompt);

		if (buffer == NULL || !strlen(buffer))
			return def;
		buffer[0] = g_ascii_toupper(buffer[0]);
		if (g_strcmp0(buffer, "Y") == 0)
			return TRUE;
		if (g_strcmp0(buffer, "N") == 0)
			return FALSE;

		if (prompt == NULL) {
			g_autoptr(GString) str = g_string_new(NULL);
			g_string_append_printf(str,
					       /* TRANSLATORS: the user isn't reading the question
						  -- %1 is 'Y' and %2 is 'N' */
					       _("Please enter either %s or %s:"),
					       "Y",
					       "N");
			g_string_append(str, " ");
			prompt = g_string_free(g_steal_pointer(&str), FALSE);
		}
	} while (TRUE);
	return FALSE;
}

static GPtrArray *
fu_console_strsplit_words(const gchar *text, guint line_len)
{
	g_auto(GStrv) tokens = NULL;
	g_autoptr(GPtrArray) lines = g_ptr_array_new_with_free_func(g_free);
	g_autoptr(GString) curline = g_string_new(NULL);

	/* sanity check */
	if (text == NULL || text[0] == '\0')
		return NULL;
	if (line_len == 0)
		return NULL;

	/* tokenize the string */
	tokens = g_strsplit(text, " ", -1);
	for (guint i = 0; tokens[i] != NULL; i++) {
		/* current line plus new token is okay */
		if (curline->len + fu_strwidth(tokens[i]) < line_len) {
			g_string_append_printf(curline, "%s ", tokens[i]);
			continue;
		}

		/* too long, so remove space, add newline and dump */
		if (curline->len > 0)
			g_string_truncate(curline, curline->len - 1);
		g_ptr_array_add(lines, g_strdup(curline->str));
		g_string_truncate(curline, 0);
		g_string_append_printf(curline, "%s ", tokens[i]);
	}

	/* any incomplete line? */
	if (curline->len > 0) {
		g_string_truncate(curline, curline->len - 1);
		g_ptr_array_add(lines, g_strdup(curline->str));
	}
	return g_steal_pointer(&lines);
}

static void
fu_console_box_line(const gchar *start,
		    const gchar *text,
		    const gchar *end,
		    const gchar *padding,
		    guint width)
{
	guint offset = 0;
	if (start != NULL) {
		offset += fu_strwidth(start);
		g_print("%s", start);
	}
	if (text != NULL) {
		offset += fu_strwidth(text);
		g_print("%s", text);
	}
	if (end != NULL)
		offset += fu_strwidth(end);
	for (guint i = offset; i < width; i++)
		g_print("%s", padding);
	if (end != NULL)
		g_print("%s\n", end);
}

void
fu_console_line(FuConsole *self, guint width)
{
	g_autoptr(GString) str = g_string_new_len(NULL, width);
	for (guint i = 0; i < width; i++)
		g_string_append(str, "─");
	fu_console_print_literal(self, str->str);
}

void
fu_console_box(FuConsole *self, const gchar *title, const gchar *body, guint width)
{
	/* nothing to do */
	if (title == NULL && body == NULL)
		return;

	/* header */
	fu_console_reset_line(self);
	fu_console_box_line("╔", NULL, "╗", "═", width);

	/* optional title */
	if (title != NULL) {
		g_autoptr(GPtrArray) lines = fu_console_strsplit_words(title, width - 4);
		for (guint j = 0; j < lines->len; j++) {
			const gchar *line = g_ptr_array_index(lines, j);
			fu_console_box_line("║ ", line, " ║", " ", width);
		}
	}

	/* join */
	if (title != NULL && body != NULL)
		fu_console_box_line("╠", NULL, "╣", "═", width);

	/* optional body */
	if (body != NULL) {
		gboolean has_nonempty = FALSE;
		g_auto(GStrv) split = g_strsplit(body, "\n", -1);
		for (guint i = 0; split[i] != NULL; i++) {
			g_autoptr(GPtrArray) lines = fu_console_strsplit_words(split[i], width - 4);
			if (lines == NULL) {
				if (has_nonempty) {
					fu_console_box_line("║ ", NULL, " ║", " ", width);
					has_nonempty = FALSE;
				}
				continue;
			}
			for (guint j = 0; j < lines->len; j++) {
				const gchar *line = g_ptr_array_index(lines, j);
				fu_console_box_line("║ ", line, " ║", " ", width);
			}
			has_nonempty = TRUE;
		}
	}

	/* footer */
	fu_console_box_line("╚", NULL, "╝", "═", width);
}

static const gchar *
fu_console_status_to_string(FwupdStatus status)
{
	switch (status) {
	case FWUPD_STATUS_IDLE:
		/* TRANSLATORS: daemon is inactive */
		return _("Idle…");
		break;
	case FWUPD_STATUS_DECOMPRESSING:
		/* TRANSLATORS: decompressing the firmware file */
		return _("Decompressing…");
		break;
	case FWUPD_STATUS_LOADING:
		/* TRANSLATORS: parsing the firmware information */
		return _("Loading…");
		break;
	case FWUPD_STATUS_DEVICE_RESTART:
		/* TRANSLATORS: restarting the device to pick up new F/W */
		return _("Restarting device…");
		break;
	case FWUPD_STATUS_DEVICE_READ:
		/* TRANSLATORS: reading from the flash chips */
		return _("Reading…");
		break;
	case FWUPD_STATUS_DEVICE_WRITE:
		/* TRANSLATORS: writing to the flash chips */
		return _("Writing…");
		break;
	case FWUPD_STATUS_DEVICE_ERASE:
		/* TRANSLATORS: erasing contents of the flash chips */
		return _("Erasing…");
		break;
	case FWUPD_STATUS_DEVICE_VERIFY:
		/* TRANSLATORS: verifying we wrote the firmware correctly */
		return _("Verifying…");
		break;
	case FWUPD_STATUS_SCHEDULING:
		/* TRANSLATORS: scheduling an update to be done on the next boot */
		return _("Scheduling…");
		break;
	case FWUPD_STATUS_DOWNLOADING:
		/* TRANSLATORS: downloading from a remote server */
		return _("Downloading…");
		break;
	case FWUPD_STATUS_WAITING_FOR_AUTH:
		/* TRANSLATORS: waiting for user to authenticate */
		return _("Authenticating…");
		break;
	case FWUPD_STATUS_DEVICE_BUSY:
	case FWUPD_STATUS_WAITING_FOR_USER:
		/* TRANSLATORS: waiting for device to do something */
		return _("Waiting…");
		break;
	default:
		break;
	}

	/* TRANSLATORS: current daemon status is unknown */
	return _("Unknown");
}

static gboolean
_fu_status_is_predictable(FwupdStatus status)
{
	if (status == FWUPD_STATUS_DEVICE_ERASE)
		return TRUE;
	if (status == FWUPD_STATUS_DEVICE_VERIFY)
		return TRUE;
	if (status == FWUPD_STATUS_DEVICE_READ)
		return TRUE;
	if (status == FWUPD_STATUS_DEVICE_WRITE)
		return TRUE;
	if (status == FWUPD_STATUS_DOWNLOADING)
		return TRUE;
	return FALSE;
}

static gboolean
fu_console_estimate_ready(FuConsole *self, guint percentage)
{
	gdouble old;
	gdouble elapsed;

	/* now invalid */
	if (percentage == 0 || percentage == 100) {
		g_timer_start(self->time_elapsed);
		self->last_estimate = 0;
		return FALSE;
	}

	/* allow-list things that make sense... */
	if (!_fu_status_is_predictable(self->status))
		return FALSE;

	old = self->last_estimate;
	elapsed = g_timer_elapsed(self->time_elapsed, NULL);
	self->last_estimate = elapsed / percentage * (100 - percentage);

	/* estimate is ready if we have decreased */
	return old > self->last_estimate;
}

static gchar *
fu_console_time_remaining_str(FuConsole *self)
{
	/* less than 5 seconds remaining */
	if (self->last_estimate < 5)
		return NULL;

	/* less than 60 seconds remaining */
	if (self->last_estimate < 60) {
		/* TRANSLATORS: time remaining for completing firmware flash */
		return g_strdup(_("Less than one minute remaining"));
	}

	return g_strdup_printf(
	    /* TRANSLATORS: more than a minute */
	    ngettext("%.0f minute remaining", "%.0f minutes remaining", self->last_estimate / 60),
	    self->last_estimate / 60);
}

static void
fu_console_refresh(FuConsole *self)
{
	const gchar *title;
	guint i;
	g_autoptr(GString) str = g_string_new(NULL);

	/* sanity check */
	if (self->status == FWUPD_STATUS_IDLE || self->status == FWUPD_STATUS_UNKNOWN)
		return;

	/* erase previous line */
	fu_console_erase_line(self);

	/* add status */
	title = fu_console_status_to_string(self->status);
	g_string_append(str, title);
	for (i = fu_strwidth(str->str); i < self->length_status; i++)
		g_string_append_c(str, ' ');

	/* add console */
	g_string_append(str, "[");
	if (self->percentage > 0) {
		for (i = 0; i < (self->length_percentage - 1) * self->percentage / 100; i++)
			g_string_append_c(str, '*');
		for (i = i + 1; i < self->length_percentage; i++)
			g_string_append_c(str, ' ');
	} else {
		const gchar chars[] = {
		    '-',
		    '\\',
		    '|',
		    '/',
		};
		for (i = 0; i < self->spinner_idx; i++)
			g_string_append_c(str, ' ');
		g_string_append_c(str, chars[i / 4 % G_N_ELEMENTS(chars)]);
		for (i = i + 1; i < self->length_percentage - 1; i++)
			g_string_append_c(str, ' ');
	}
	g_string_append_c(str, ']');

	/* once we have good data show an estimate of time remaining */
	if (fu_console_estimate_ready(self, self->percentage)) {
		g_autofree gchar *remaining = fu_console_time_remaining_str(self);
		if (remaining != NULL)
			g_string_append_printf(str, " %s…", remaining);
	}

	/* dump to screen */
	g_print("%s", str->str);
	self->contents_to_clear = TRUE;
}

/**
 * fu_console_print_full:
 * @self: a #FuConsole
 * @flags; a #FuConsolePrintFlags, e.g. %FU_CONSOLE_PRINT_FLAG_STDERR
 * @text: string
 *
 * Clears the console, and prints the text.
 **/
void
fu_console_print_full(FuConsole *self, FuConsolePrintFlags flags, const gchar *format, ...)
{
	va_list args;
	g_autoptr(GString) str = g_string_new(NULL);

	va_start(args, format);
	g_string_append_vprintf(str, format, args);
	va_end(args);

	if (flags & FU_CONSOLE_PRINT_FLAG_LIST_ITEM)
		g_string_prepend(str, " • ");

	if (flags & FU_CONSOLE_PRINT_FLAG_WARNING) {
		/* TRANSLATORS: this is a prefix on the console */
		g_autofree gchar *fmt = fu_console_color_format(_("WARNING"), FU_CONSOLE_COLOR_RED);
		g_string_prepend(str, ": ");
		g_string_prepend(str, fmt);
		flags |= FU_CONSOLE_PRINT_FLAG_STDERR;
	}

	if (flags & FU_CONSOLE_PRINT_FLAG_NEWLINE)
		g_string_append(str, "\n");

	fu_console_reset_line(self);
	if (flags & FU_CONSOLE_PRINT_FLAG_STDERR) {
		g_printerr("%s", str->str);
	} else {
		g_print("%s", str->str);
	}
}

void
fu_console_print_literal(FuConsole *self, const gchar *text)
{
	fu_console_reset_line(self);
	g_print("%s\n", text);
}

/**
 * fu_console_print:
 * @self: a #FuConsole
 * @text: string
 *
 * Clears the console, prints the text and prints a newline.
 **/
void
fu_console_print(FuConsole *self, const gchar *format, ...)
{
	va_list args;
	g_autofree gchar *tmp = NULL;

	va_start(args, format);
	tmp = g_strdup_vprintf(format, args);
	va_end(args);
	fu_console_print_literal(self, tmp);
}

/**
 * fu_console_set_progress_title:
 * @self: A #FuConsole
 * @title: A string
 *
 * Sets console title
 **/
void
fu_console_set_progress_title(FuConsole *self, const gchar *title)
{
	if (!self->interactive)
		return;
	fu_console_erase_line(self);
	g_print("%s\n", title);
	fu_console_refresh(self);
}

/**
 * fu_console_set_main_context:
 * @self: A #FuConsole
 * @main_ctx: (nullable): main context
 *
 * Sets console main context to use for animations.
 **/
void
fu_console_set_main_context(FuConsole *self, GMainContext *main_ctx)
{
	self->main_ctx = g_main_context_ref(main_ctx);
}

static void
fu_console_spin_inc(FuConsole *self)
{
	/* reset */
	self->last_animated = g_get_monotonic_time();

	/* up to down */
	if (self->spinner_count_up) {
		if (++self->spinner_idx > self->length_percentage - 3)
			self->spinner_count_up = FALSE;
	} else {
		if (--self->spinner_idx == 0)
			self->spinner_count_up = TRUE;
	}
}

static gboolean
fu_console_spin_cb(gpointer user_data)
{
	FuConsole *self = FU_CONSOLE(user_data);

	/* move the spinner index up to down */
	fu_console_spin_inc(self);

	/* update the terminal */
	fu_console_refresh(self);

	return G_SOURCE_CONTINUE;
}

static void
fu_console_spin_end(FuConsole *self)
{
	if (self->timer_source != NULL) {
		g_source_destroy(self->timer_source);
		self->timer_source = NULL;

		/* reset when the spinner has been stopped */
		g_timer_start(self->time_elapsed);
	}

	/* go back to the start when we next go into unknown percentage mode */
	self->spinner_idx = 0;
	self->spinner_count_up = TRUE;
}

static void
fu_console_spin_start(FuConsole *self)
{
	if (self->timer_source != NULL)
		g_source_destroy(self->timer_source);
	self->timer_source = g_timeout_source_new(40);
	g_source_set_callback(self->timer_source, fu_console_spin_cb, self, NULL);
	g_source_attach(self->timer_source, self->main_ctx);
}

/**
 * fu_console_set_progress:
 * @self: A #FuConsole
 * @status: A #FwupdStatus
 * @percentage: unsigned integer
 *
 * Refreshes the progress bar with the new percentage and status.
 **/
void
fu_console_set_progress(FuConsole *self, FwupdStatus status, guint percentage)
{
	g_return_if_fail(FU_IS_CONSOLE(self));

	/* not useful */
	if (status == FWUPD_STATUS_UNKNOWN)
		return;

	/* ignore duplicates */
	if (self->status == status && self->percentage == percentage)
		return;

	/* cache */
	self->status = status;
	self->percentage = percentage;

	/* dumb */
	if (!self->interactive) {
		g_printerr("%s: %u%%\n", fu_console_status_to_string(status), percentage);
		return;
	}

	/* if the main loop isn't spinning and we've not had a chance to
	 * execute the callback just do the refresh now manually */
	if (percentage == 0 && status != FWUPD_STATUS_IDLE &&
	    self->status != FWUPD_STATUS_UNKNOWN) {
		if ((g_get_monotonic_time() - self->last_animated) / 1000 > 40) {
			fu_console_spin_inc(self);
			fu_console_refresh(self);
		}
	}

	/* enable or disable the spinner timeout */
	if (percentage > 0) {
		fu_console_spin_end(self);
	} else {
		fu_console_spin_start(self);
	}

	/* update the terminal */
	fu_console_refresh(self);
}

/**
 * fu_console_set_interactive:
 * @self: A #FuConsole
 * @interactive: #gboolean
 *
 * Marks the console as interactive or not
 **/
void
fu_console_set_interactive(FuConsole *self, gboolean interactive)
{
	g_return_if_fail(FU_IS_CONSOLE(self));
	self->interactive = interactive;
}

/**
 * fu_console_set_status_length:
 * @self: A #FuConsole
 * @len: unsigned integer
 *
 * Sets the width of the progressbar status, which must be greater that 3.
 **/
void
fu_console_set_status_length(FuConsole *self, guint len)
{
	g_return_if_fail(FU_IS_CONSOLE(self));
	g_return_if_fail(len > 3);
	self->length_status = len;
}

/**
 * fu_console_set_percentage_length:
 * @self: A #FuConsole
 * @len: unsigned integer
 *
 * Sets the width of the progressbar percentage, which must be greater that 3.
 **/
void
fu_console_set_percentage_length(FuConsole *self, guint len)
{
	g_return_if_fail(FU_IS_CONSOLE(self));
	g_return_if_fail(len > 3);
	self->length_percentage = len;
}

void
fu_console_beep(FuConsole *self, guint count)
{
	for (guint i = 0; i < count; i++) {
		if (i > 0)
			g_usleep(250000);
		g_print("\007");
	}
}

static void
fu_console_init(FuConsole *self)
{
	self->length_percentage = 40;
	self->length_status = 25;
	self->spinner_count_up = TRUE;
	self->time_elapsed = g_timer_new();
	self->interactive = TRUE;
}

static void
fu_console_finalize(GObject *obj)
{
	FuConsole *self = FU_CONSOLE(obj);

	fu_console_reset_line(self);
	if (self->timer_source != 0)
		g_source_destroy(self->timer_source);
	if (self->main_ctx != NULL)
		g_main_context_unref(self->main_ctx);
	g_timer_destroy(self->time_elapsed);

	G_OBJECT_CLASS(fu_console_parent_class)->finalize(obj);
}

static void
fu_console_class_init(FuConsoleClass *klass)
{
	GObjectClass *object_class = G_OBJECT_CLASS(klass);
	object_class->finalize = fu_console_finalize;
}

/**
 * fu_console_new:
 *
 * Creates a new #FuConsole
 **/
FuConsole *
fu_console_new(void)
{
	FuConsole *self;
	self = g_object_new(FU_TYPE_CONSOLE, NULL);
	return FU_CONSOLE(self);
}
