/*
 * This file is part of the Amalthea library.
 * Copyright (C) 2018-2021, 2024, 2026 Eugene 'Vindex' Stulin
 * Distributed under the BSL 1.0 or the GNU LGPL 3.0 or later.
 */

/**
 * Wrapper for the "dialog" utility for pseudo-graphic user interface.
 */
module amalthea.dialog;

import std.array : split;
import std.conv : to;
import std.datetime : Clock, Date, SysTime, TimeOfDay;
import std.file : FileException;
import std.process : environment, execute;
import std.string;
import std.typecons;

public import amalthea.libcore;

/*******************************************************************************
 * Enuration for actions in dialog boxes.
 */
enum DialogAction {
    YES = 0,
    NO = 1,
    OK = YES,
    CANCEL = NO,
    ESC = 255
}
private alias Action = DialogAction;

private alias fn_t = void delegate();


/*******************************************************************************
 * Base class for all `dialog` user interface boxes.
 *
 * `dialog` is a program used which displays text user interface widgets.
 * - - -
 * See_Also: $(EXT_LINK https://invisible-island.net/dialog/)
 */
abstract class Dialog {

    /// Background title, can be used of an application name.
    protected static string backtitle = "";

    /***************************************************************************
     * Sets the background title for dialog widgets.
     */
    static void setBackTitle(string btitle) {
        backtitle = btitle;
    }

    /***************************************************************************
     * Returns current background title.
     */
    static string getBackTitle() {
        return backtitle;
    }

    protected immutable baseCmd = ["dialog", "--stdout"];

    protected fn_t[Action] callbacks;

    protected string title;
    protected string dialogType;
    protected string text;
    protected uint height, width;

    /***************************************************************************
     * Base constructor for all dialog boxes.
     * Params:
     *     dialogType = A box name (msgbox, calendar, checklist, menu, etc.)
     *     title = A form name, a title for current box example.
     *     text = Main message of the box (for description or clarification).
     *     h = Height, number of lines.
     *     w = Width, number of columns.
     */
    this(string dialogType, string title, string text, uint h,uint w) {
        this.dialogType = dialogType;
        this.title = title;
        this.text = text;
        this.height = h;// + 4; //4 rows are parts of border
        this.width = w;

        callbacks[Action.YES] = null;
        callbacks[Action.NO]  = null;
        callbacks[Action.ESC] = null;
    }

    /***************************************************************************
     * It is possible to assign callbacks to events such as pressing
     * OK (or YES), NO (or CANCEL), ESC.
     * ESC is always available.
     */
    void setButtonHandler(Action act, fn_t fn) {
        callbacks[act] = fn;
    }

    /***************************************************************************
     * Changes a box text.
     */
    void setText(string text) {
        this.text = text;
    }

    protected string[] prepareCmd() {
        return baseCmd ~ [
            "--backtitle", getBackTitle(),
            "--title", this.title,
            "--"~dialogType, this.text,
            this.height.to!string, this.width.to!string
        ];
    }


    /***************************************************************************
     * Execution launch.
     */
    void run() {
        string[] cmdArray = prepareCmd();
        auto answer = execute(cmdArray);
        this.entry = std.string.strip(answer.output);
        if (this.entry.startsWith("Can't make new window")) {
            throw new BadWindowSize(this.entry);
        }
        if (this.entry.startsWith("Can't open input file")) {
            throw new FileException(this.entry);
        }
        auto f = callbacks[cast(Action)answer.status];
        if (f !is null) {
            f();
        }
    }

    protected string entry;
}


/*******************************************************************************
 * Template with simplified constructor for many dialog classes.
 */
mixin template Constructor(alias boxname) {
    /***************************************************************************
     * Creates an object.
     * Params:
     *     title = a form name, a title for current box example
     *     text = main message of the box (for description or clarification)
     *     h = height, number of lines
     *     w = width, number of columns
     */
    this(string title="", string text="", uint h=8, uint w=48) {
        super(boxname, title, text, h, w);
    }
}


/*******************************************************************************
 * Template with constructor for Calendar and TimeBox.
 */
mixin template DateTimeConstructor(alias boxname) {
    /***************************************************************************
     * Creates an object.
     * Params:
     *     title = a form name, a title for current box example
     *     text = main message of the box (for description or clarification)
     */
    this(string title="", string text="") {
        super(boxname, title, text, 0, 0);
    }
}


/*******************************************************************************
 * A yes/no dialog box.
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 Constructor)!"yesno"`.
 */
class YesNo : Dialog {
    mixin Constructor!"yesno";
}


/*******************************************************************************
 * A message box has only a single OK button.
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 Constructor)!"msgbox"`.
 */
class MsgBox : Dialog {
    mixin Constructor!"msgbox";
}


/*******************************************************************************
 * A info box has no buttons and closes after a specified time interval.
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 Constructor)!"infobox"`.
 */
class InfoBox : Dialog {
    mixin Constructor!"infobox";

    /***************************************************************************
     * The function sets the lifetime of the widget.
     */
    void setSleepTime(uint sleepTime) {
        this.sleepTime = sleepTime;
    }

    override string[] prepareCmd() {
        return baseCmd ~ [
            "--backtitle", backtitle,
            "--title", this.title,
            "--sleep", to!string(this.sleepTime),
            "--"~dialogType, this.text,
            this.height.to!string, this.width.to!string
        ];
    }

    protected uint sleepTime = 0;
}


/*******************************************************************************
 * An input box is useful when you want to ask questions that require the user
 * to input a string as the answer (description taken from `man dialog`).
 * Provides a field for entering text.
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 Constructor)!"inputbox"`.
 */
class InputBox : Dialog {
    mixin Constructor!"inputbox";

    /***************************************************************************
     * This method provides access to the saved string entered by user.
     * Note: call after run() only.
     */
    string getEntry() {
        return entry;
    }
}


/*******************************************************************************
 * A password box is similar to an input box, except that the text the user
 * enters is not displayed.
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 Constructor)!"passwordbox"`.
 */
class PasswordBox : Dialog {
    mixin Constructor!"passwordbox";

    /***************************************************************************
     * This method provides access to the saved string entered by user.
     * Note: call after run() only.
     */
    string getEntry() {
        return entry;
    }
}


protected struct Item {
    string tag;
    string descr;
    bool status;
}


/*******************************************************************************
 * Template contains methods for CheckList, RadioList, BuildList.
 */
mixin template ListBoxFunctional(T) {
    /***************************************************************************
     * Adds new item.
     */
    T addItem(string tag, string descr, bool status=false) {
        foreach(elem; items) {
            if (elem.tag == tag) {
                throw new RepeatedItem(
                    tag ~ ": item with such tag already exists."
                );
            }
        }
        items ~= Item(tag, descr, status);
        return this;
    }

    override string[] prepareCmd() {
        return baseCmd ~ [
            "--backtitle", backtitle,
            "--title", this.title,
            "--"~dialogType, this.text,
            this.height.to!string, this.width.to!string, "0"
        ];
    }

    override void run() {
        auto cmdArray = prepareCmd();
        foreach (item; items) {
            auto state = item.status ? "on" : "off";
            cmdArray ~= [item.tag, item.descr, state];
        }
        auto answer = execute(cmdArray);
        if (answer.output.startsWith("Can't make new window")) {
            throw new BadWindowSize(this.entry);
        }
        selectedItems = split(answer.output);
        auto f = callbacks[cast(Action)answer.status];
        if (f !is null) {
            f();
        }
    }

    protected string[] selectedItems;
    protected Item[] items;
}


/*******************************************************************************
 * There are multiple entries presented in the form of a menu.
 * Mixins:
 *     Uses mixin-templates `$(DIALOG_REF2 Constructor)!"checklist"`
 *     and `$(DIALOG_REF2 ListBoxFunctional)!CheckList`.
 */
class CheckList : Dialog {
    mixin Constructor!"checklist";
    mixin ListBoxFunctional!CheckList;

    /***************************************************************************
     * This method provides access to the selected names of checklist items.
     * Note: call after run() only.
     */
    string[] getSelectedItems() {
        return selectedItems;
    }
}


/*******************************************************************************
 * Provides a list to select one item.
 * Mixins:
 *     Uses mixin-templates `$(DIALOG_REF2 Constructor)!"radiolist"`
 *     and `$(DIALOG_REF2 ListBoxFunctional)!RadioList`.
 */
class RadioList : Dialog {
    mixin Constructor!"radiolist";
    mixin ListBoxFunctional!RadioList;

    /***************************************************************************
     * This method provides access to the selected item tag.
     * Note: call after run() only.
     */
    string getSelectedItem() {
        if (!selectedItems.empty) {
            return selectedItems[0];
        }
        return "";
    }
}


/*******************************************************************************
 * A buildlist dialog displays two lists, side-by-side.
 * The list on the left shows unselected items.
 * The list on the right shows selected items.
 * As items are selected or unselected, they move between the lists.
 * (Description taken from `man dialog`.)
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 Constructor)!"buildlist"`
 *     and `$(DIALOG_REF2 ListBoxFunctional)!BuildList`.
 */
class BuildList : Dialog {
    mixin Constructor!"buildlist";
    mixin ListBoxFunctional!BuildList;

    /***************************************************************************
     * This method provides access to the selected names of buildlist items.
     * Note: call after run() only.
     */
    string[] getSelectedItems() {
        return selectedItems;
    }
}


private struct MenuElement {
    string tag;
    string descr;
    fn_t handler;
}


/*******************************************************************************
 * A dialog box that can be used to present a list of choices in  the form of
 * a menu for the user to choose (description taken from `man dialog`).
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 Constructor)!"menu"`.
 */
class Menu : Dialog {
    mixin Constructor!"menu";

    /***************************************************************************
     * Adds new item in menu.
     */
    Menu addItem(string tag, string description, fn_t fn=null) {
        // check all previous items
        foreach(item; items) {
            if (item.tag == tag) {
                throw new RepeatedItem(
                    tag ~ ": item with such tag already exists."
                );
            }
        }
        // add new element in a special array
        items ~= MenuElement(tag, description, fn);
        // for conveyor calls
        return this;
    }

    /***************************************************************************
     * Assign a callback to a specific menu item.
     */
    void setItemHandler(string tag, fn_t fn) {
        foreach(ref item; items) {
            if (item.tag != tag) {
                continue;
            }
            item.handler = fn;
        }
    }

    override string[] prepareCmd() {
        auto menuHeight = items.length.to!string;
        return baseCmd ~ [
            "--backtitle", backtitle,
            "--title", this.title,
            "--"~dialogType, this.text,
            this.height.to!string, this.width.to!string, menuHeight
        ];
    }

    override void run() {
        string[] cmdArray = prepareCmd();
        foreach (item; this.items) {
            cmdArray ~= [item.tag, item.descr];
        }
        auto answer = execute(cmdArray);
        if (answer.output.startsWith("Can't make new window")) {
            throw new BadWindowSize(this.entry);
        }
        this.tag = std.string.strip(answer.output);
        auto action = cast(Action)answer.status;
        auto f = callbacks[action];
        if (f !is null) {
            f();
        }
        if (action != Action.OK) {
            return;
        }
        foreach(item; items) {
            if (item.tag != this.tag) {
                continue;
            }
            auto itemFn = item.handler;
            if (itemFn !is null) {
                itemFn();
            }
        }
    }

    string getChosenTag() {
        return tag;
    }

    protected string tag;
    protected MenuElement[] items;
}


/*******************************************************************************
 * A calendar box displays month, day and year in separately adjustable windows.
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 DateTimeConstructor)!"calendar"`.
 */
class Calendar : Dialog {
    mixin DateTimeConstructor!"calendar";

    override string[] prepareCmd() {
        SysTime t = Clock.currTime();
        return baseCmd ~ [
            "--backtitle", backtitle,
            "--title", this.title,
            "--"~dialogType, this.text,
            this.height.to!string, this.width.to!string,
            to!string(t.day), to!string(cast(int)t.month), to!string(t.year)
        ];
    }

    /***************************************************************************
     * Returns tuple with year, month and day.
     * Note: call after run() only.
     */
    Tuple!(int, "year", int, "month", int, "day") getDateTuple() {
        if (this.entry == "") {
            return tuple!("year", "month", "day")(1970, 1, 1);
        }
        auto dateArray = split(this.entry, "/");
        return tuple!("year", "month", "day")
            (to!int(dateArray[2]), to!int(dateArray[1]), to!int(dateArray[0]));
    }

    /***************************************************************************
     * Returns object of `std.datetime.date.Date`.
     * Note: call after run() only.
     */
    Date getDate() {
        auto dateTuple = this.getDateTuple();
        return Date(dateTuple.year, dateTuple.month, dateTuple.day);
    }
}


/*******************************************************************************
 * A dialog is displayed which allows you to select hour, minute and second.
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 DateTimeConstructor)!"timebox"`.
 */
class TimeBox : Dialog {
    mixin DateTimeConstructor!"timebox";

    override string[] prepareCmd() {
        SysTime t = Clock.currTime();
        return baseCmd ~ [
            "--backtitle", backtitle,
            "--title", this.title,
            "--"~dialogType, this.text,
            this.height.to!string, this.width.to!string,
            to!string(t.hour), to!string(t.minute), to!string(t.second)
        ];
    }

    /***************************************************************************
     * Returns tuple with hour, minute and second. Call after run() only.
     */
    auto getTimeTuple() {
        auto timeArray = split(this.entry, ":");
        return tuple!("hour", "minute", "second")
            (to!int(timeArray[0]), to!int(timeArray[1]), to!int(timeArray[2]));
    }

    /***************************************************************************
     * Returns object of `std.datetime.date.TimeOfDay`.
     * Note: call after run() only.
     */
    TimeOfDay getTime() {
        return TimeOfDay.fromISOExtString(this.entry);
    }
}


/*******************************************************************************
 * Template contains methods for template FileSystemBox
 * and for classes EditBox and TextBox.
 */
mixin template FileBox(alias boxName, alias height, alias width) {
    /***************************************************************************
     * Creates an object.
     *
     * Params:
     *     title = a form name, a title for current box example
     *     path = file (or directory) path
     *     h = height, number of lines
     *     w = width, number of columns
     */
    this(string title="", string path="", uint h=height, uint w=width) {
        if (path == "") {
            path = environment.get("HOME", "") ~ "/";
        }
        super(boxName, title, path, h, w);
    }

    /***************************************************************************
     * Sets start path.
     */
    void setPath(string path) {
        this.text = path;
    }
}


/*******************************************************************************
 * Template with base implementation of methods for FSelect, DSelect.
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 FileBox)!(boxName, 10, 60)`.
 */
mixin template FileSystemBox(alias boxName) {
    mixin FileBox!(boxName, 10, 60);

    /***************************************************************************
     * Gets a user-selected file path.
     * Note: call after run() only.
     */
    string getPath() {
        return this.entry;
    }
}


/*******************************************************************************
 * FSelect displays a text-entry window in which you can type
 * a filename (or directory), and above that - two windows with directory names
 * and filenames (description taken from `man dialog`, the `--fselect` section).
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 FileSystemBox)!"fselect"`.
 */
class FSelect : Dialog {
    /// FS functions.
    mixin FileSystemBox!"fselect";
}


/*******************************************************************************
 * DSelect displays a text-entry window in which you can type
 * a directory, and above that - a window with directory names
 * (description taken from `man dialog`, the `--dselect` section).
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 FileSystemBox)!"dselect"`.
 */
class DSelect : Dialog {
    mixin FileSystemBox!"dselect";
}


/*******************************************************************************
 * Allow the user to select from a range of values, e.g., using a slider.
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 Constructor)!"rangebox"`.
 */
class RangeBox : Dialog {
    mixin Constructor!"rangebox";

    override string[] prepareCmd() {
        return baseCmd ~ [
            "--backtitle", backtitle,
            "--title", this.title,
            "--"~dialogType, this.text,
            this.height.to!string, this.width.to!string,
            this.minValue.to!string,
            this.maxValue.to!string,
            this.defaultValue.to!string
        ];
    }

    /***************************************************************************
     * Sets a range of valid values and a default value.
     */
    void setRange(long minValue, ulong maxValue, ulong defaultValue) {
        this.minValue = minValue;
        this.maxValue = maxValue;
        this.defaultValue = defaultValue;
    }

    /***************************************************************************
     * This method provides access to the selected value.
     * Note: call after run() only.
     */
    long getValue() {
        return entry.to!long;
    }

protected:

    long minValue = 0;
    long maxValue = 100;
    long defaultValue = 50;
}


/*******************************************************************************
 * A pause box displays a meter along the bottom of the box.
 * The meter indicates how many seconds remain until the end of the pause.
 * This box exits when timeout is reached or the user presses
 * the OK button (status OK) or the user presses the CANCEL button or Esc key.
 * (Description taken from `man dialog`.)
 */
class Pause : Dialog {
    /***************************************************************************
     * Creates an object.
     *
     * Params:
     *     title = a form name, a title for current box example
     *     text = main message of the box (for description or clarification)
     *     h = height, number of lines
     *     w = width, number of columns
     *     t = time to countdown
     */
    this(string title="", string text="", uint h=8, uint w=48, uint t=10) {
        this.timeout = t;
        super("pause", title, text, h, w);
    }

    /***************************************************************************
     * Sets countdown.
     */
    void setTimeout(uint t) {
        this.timeout = t;
    }

    override string[] prepareCmd() {
        return baseCmd ~ [
            "--backtitle", backtitle,
            "--title", this.title,
            "--"~dialogType, this.text,
            this.height.to!string, this.width.to!string,
            this.timeout.to!string
        ];
    }

    protected uint timeout;
}


/*******************************************************************************
 * A text box lets you display the contents of a text file in a dialog box.
 * It is like a simple text file viewer.
 * (Description taken from `man dialog`.)
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 FileBox)!("textbox", 16, 64)`.
 */
class TextBox : Dialog {
    mixin FileBox!("textbox", 16, 64);
}


/*******************************************************************************
 * This window displays a copy of the file for editing.
 * After editing, changed file content will be saved in this object.
 * Mixins:
 *     Uses mixin-template `$(DIALOG_REF2 FileBox)!("textbox", 16, 64)`.
 */
class EditBox : Dialog {
    mixin FileBox!("editbox", 16, 64);

    /***************************************************************************
     * Returns changed content of the file.
     * Note: Call after run() only.
     */
    string getContent() {
        return this.entry;
    }
}


/*******************************************************************************
 * Base exception for the dialog module.
 */
abstract class DialogException : Exception {
    mixin RealizeException;
}


/*******************************************************************************
 * Exception for list-based dialog boxes.
 * Thrown if an attempt was made to add an item with a duplicate tag.
 */
class RepeatedItem : DialogException {
    mixin RealizeException;
}


/*******************************************************************************
 * Thrown if a dialog window cannot be created.
 */
class BadWindowSize : DialogException {
    mixin RealizeException;
}

