/* The Mia! Accounting Project
 * description-editor.js: The JavaScript for the description editor
 */

/*  Copyright (c) 2023 imacat.
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

/* Author: imacat@mail.imacat.idv.tw (imacat)
 * First written: 2023/2/28
 */
"use strict";

/**
 * A description editor.
 *
 */
class DescriptionEditor {

    /**
     * The line item editor
     * @type {JournalEntryLineItemEditor}
     */
    lineItemEditor;

    /**
     * The description editor form
     * @type {HTMLFormElement}
     */
    #form;

    /**
     * The prefix of the HTML ID and class names
     * @type {string}
     */
    prefix;

    /**
     * The modal of the description editor
     * @type {HTMLDivElement}
     */
    #modal;

    /**
     * Either "debit" or "credit"
     * @type {string}
     */
    debitCredit;

    /**
     * The current tab
     * @type {DescriptionEditorTabPlane}
     */
    currentTab;

    /**
     * The description input
     * @type {HTMLInputElement}
     */
    #descriptionInput;

    /**
     * The button to the original line item selector
     * @type {HTMLButtonElement}
     */
    #offsetButton;

    /**
     * The number input
     * @type {HTMLInputElement}
     */
    number;

    /**
     * The note
     * @type {HTMLInputElement}
     */
    note;

    /**
     * The placeholder of the confirmed account
     * @type {DescriptionEditorConfirmedAccount}
     */
    #confirmedAccountPlaceholder;

    /**
     * All the suggested accounts
     * @type {DescriptionEditorSuggestedAccount[]}
     */
    #allSuggestedAccounts;

    /**
     * The current suggested accounts
     * @type {DescriptionEditorSuggestedAccount[]}
     */
    #currentSuggestedAccounts;

    /**
     * The account that the user specified or confirmed
     * @type {DescriptionEditorConfirmedAccount|null}
     */
    #confirmedAccount = null;

    /**
     * Whether the user has confirmed the account
     * @type {boolean}
     */
    isAccountConfirmed = false;

    /**
     * The selected account.
     * @type {DescriptionEditorAccount|null}
     */
    selectedAccount = null;

    /**
     * The tab planes
     * @type {{general: DescriptionEditorGeneralTagTab, travel: DescriptionEditorGeneralTripTab, bus: DescriptionEditorBusTripTab, recurring: DescriptionEditorRecurringTab, annotation: DescriptionEditorAnnotationTab}}
     */
    tabPlanes = {};

    /**
     * Constructs a description editor.
     *
     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
     * @param debitCredit {string} either "debit" or "credit"
     */
    constructor(lineItemEditor, debitCredit) {
        this.lineItemEditor = lineItemEditor;
        this.debitCredit = debitCredit;
        this.prefix = `accounting-description-editor-${debitCredit}`;
        this.#form = document.getElementById(this.prefix);
        this.#modal = document.getElementById(`${this.prefix}-modal`);
        this.#descriptionInput = document.getElementById(`${this.prefix}-description`);
        this.#offsetButton = document.getElementById(`${this.prefix}-offset`);
        this.number = document.getElementById(`${this.prefix}-annotation-number`);
        this.note = document.getElementById(`${this.prefix}-annotation-note`);
        this.#confirmedAccountPlaceholder = new DescriptionEditorConfirmedAccount(this, document.getElementById(`${this.prefix}-account-confirmed`));
        this.#allSuggestedAccounts = Array.from(document.getElementsByClassName(`${this.prefix}-account`)).map((button) => new DescriptionEditorSuggestedAccount(this, button));

        for (const cls of [DescriptionEditorGeneralTagTab, DescriptionEditorGeneralTripTab, DescriptionEditorBusTripTab, DescriptionEditorRecurringTab, DescriptionEditorAnnotationTab]) {
            const tab = new cls(this);
            this.tabPlanes[tab.tabId()] = tab;
        }
        this.currentTab = this.tabPlanes.general;
        this.#descriptionInput.onchange = () => this.#onDescriptionChange();
        this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen();
        this.#form.onsubmit = () => {
            if (this.currentTab.validate()) {
                this.#submit();
            }
            return false;
        };
    }

    /**
     * Returns the description.
     *
     * @return {string} the description
     */
    get description() {
        return this.#descriptionInput.value;
    }

    /**
     * Sets the description.
     *
     * @param description {string} the description
     */
    set description(description) {
        this.#descriptionInput.value = description;
    }

    /**
     * Returns the current account options.
     *
     * @return {DescriptionEditorAccount[]} the current account options.
     */
    get #currentAccountOptions() {
        if (this.#confirmedAccount === null) {
            return this.#currentSuggestedAccounts;
        }
        return [this.#confirmedAccount].concat(this.#currentSuggestedAccounts);
    }

    /**
     * The callback when the description input is changed.
     *
     */
    #onDescriptionChange() {
        this.#resetTabPlanes();
        this.selectedAccount = null;
        this.description = this.description.trim();
        for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
            if (tabPlane.populate()) {
                break;
            }
        }
        this.tabPlanes.annotation.populate();
    }

    /**
     * Resets the tab planes.
     *
     */
    #resetTabPlanes() {
        for (const tabPlane of Object.values(this.tabPlanes)) {
            tabPlane.reset();
        }
        this.tabPlanes.general.switchToMe();
    }

    /**
     * Updates the current suggested accounts.
     *
     * @param tagButton {HTMLButtonElement} the tag button
     */
    updateCurrentSuggestedAccounts(tagButton) {
        this.clearSuggestedAccounts();
        const suggestedAccountCodes = JSON.parse(tagButton.dataset.accounts);
        this.#currentSuggestedAccounts = this.#allSuggestedAccounts.filter((account) => {
            if (this.#confirmedAccount !== null && account.code === this.#confirmedAccount.code) {
                return false;
            }
            return suggestedAccountCodes.includes(account.code);
        });
        for (const account of this.#currentSuggestedAccounts) {
            account.setShown(true);
        }
        this.#selectSuggestedAccount(suggestedAccountCodes[0]);
    }

    /**
     * Selects the suggested account.
     *
     * @param code {string} the code of the most-frequent suggested account
     */
    #selectSuggestedAccount(code) {
        if (this.isAccountConfirmed) {
            return;
        }
        for (const account of this.#currentAccountOptions) {
            if (account.code === code) {
                this.selectAccount(account);
                return;
            }
        }
    }

    /**
     * Clears the suggested accounts.
     *
     */
    clearSuggestedAccounts() {
        for (const account of this.#allSuggestedAccounts) {
            account.setShown(false);
            account.setActive(false);
        }
        this.#currentSuggestedAccounts = [];
    }

    /**
     * Select an account.
     *
     * @param selectedAccount {DescriptionEditorAccount|null} the account, or null to deselect the account
     */
    selectAccount(selectedAccount) {
        for (const account of this.#currentAccountOptions) {
            account.setActive(false);
        }
        if (selectedAccount !== null) {
            selectedAccount.setActive(true);
        }
        this.selectedAccount = selectedAccount;
        if (this.selectedAccount !== null) {
            this.isAccountConfirmed &&= this.selectedAccount.isConfirmedAccount;
        }
    }

    /**
     * Submits the description.
     *
     */
    #submit() {
        bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
        this.lineItemEditor.saveDescription(this);
    }

    /**
     * The callback when the description editor is shown.
     *
     */
    onOpen() {
        this.description = this.lineItemEditor.description === null? "": this.lineItemEditor.description;
        this.#setConfirmedAccount();
        this.#onDescriptionChange();
        if (this.isAccountConfirmed) {
            this.selectAccount(this.#confirmedAccount);
        }
    }

    /**
     * Sets the confirmed account.
     *
     */
    #setConfirmedAccount() {
        this.isAccountConfirmed = this.lineItemEditor.isAccountConfirmed;
        this.#confirmedAccountPlaceholder.setShown(this.isAccountConfirmed);
        if (this.isAccountConfirmed) {
            this.#confirmedAccountPlaceholder.initializeFrom(this.lineItemEditor.account);
            this.#confirmedAccount = this.#confirmedAccountPlaceholder;
        } else {
            this.#confirmedAccount = null;
        }
    }

    /**
     * Returns the description editor instances.
     *
     * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
     * @return {{debit: DescriptionEditor, credit: DescriptionEditor}}
     */
    static getInstances(lineItemEditor) {
        const editors = {}
        const forms = Array.from(document.getElementsByClassName("accounting-description-editor"));
        for (const form of forms) {
            editors[form.dataset.debitCredit] = new DescriptionEditor(lineItemEditor, form.dataset.debitCredit);
        }
        return editors;
    }
}

/**
 * An account option in the description editor.
 *
 */
class DescriptionEditorAccount extends JournalEntryAccount {

    /**
     * The account button
     * @type {HTMLButtonElement}
     */
    #element;

    /**
     * Whether this is the account specified or confirmed by the user
     * @type {boolean}
     */
    isConfirmedAccount = false;

    /**
     * Constructs an account option in the description editor.
     *
     * @param editor {DescriptionEditor} the description editor
     * @param code {string} the account code
     * @param title {string} the account title
     * @param text {string} the account text
     * @param isNeedOffset {boolean} true if the line items in the account needs offset, or false otherwise
     * @param button {HTMLButtonElement} the account button
     */
    constructor(editor, code, title, text, isNeedOffset, button) {
        super(code, title, text, isNeedOffset);
        this.#element = button;
        this.#element.onclick = () => editor.selectAccount(this);
    }

    /**
     * Sets whether the option is shown.
     *
     * @param isShown {boolean} true to show, or false otherwise
     */
    setShown(isShown) {
        if (isShown) {
            this.#element.classList.remove("d-none");
        } else {
            this.#element.classList.add("d-none");
        }
    }

    /**
     * Sets whether the option is active.
     *
     * @param isActive {boolean} true if active, or false otherwise
     */
    setActive(isActive) {
        if (isActive) {
            this.#element.classList.add("btn-primary");
            this.#element.classList.remove("btn-outline-primary");
        } else {
            this.#element.classList.remove("btn-primary");
            this.#element.classList.add("btn-outline-primary");
        }
    }

    /**
     * Sets the content of the account button.
     *
     */
    resetContent() {
        this.#element.innerText = this.text;
    }
}

/**
 * A suggested account.
 *
 */
class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {

    /**
     * Constructs a suggested account.
     *
     * @param editor {DescriptionEditor} the description editor
     * @param button {HTMLButtonElement} the account button
     */
    constructor(editor, button) {
        super(editor, button.dataset.code, button.dataset.title, button.dataset.text, button.classList.contains("accounting-account-is-need-offset"), button);
    }
}

/**
 * The account option that is specified or confirmed by the user.
 *
 */
class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {

    /**
     * Constructs the account option that is specified or confirmed by the user.
     *
     * @param editor {DescriptionEditor} the description editor
     * @param button {HTMLButtonElement} the account button
     */
    constructor(editor, button) {
        super(editor, "", "", "", false, button);
        this.isConfirmedAccount = true;
    }

    /**
     * Initializes the confirmed account from the line item editor.
     *
     * @param account {JournalEntryAccount} the confirmed account from the line item editor
     */
    initializeFrom(account) {
        this.code = account.code;
        this.text = account.text;
        this.isNeedOffset = account.isNeedOffset;
        this.resetContent();
    }
}

/**
 * A tab plane.
 *
 * @abstract
 * @private
 */
class DescriptionEditorTabPlane {

    /**
     * The parent description editor
     * @type {DescriptionEditor}
     */
    editor;

    /**
     * The prefix of the HTML ID and class names
     * @type {string}
     */
    prefix;

    /**
     * The tab
     * @type {HTMLSpanElement}
     */
    #tab;

    /**
     * The page
     * @type {HTMLDivElement}
     */
    #page;

    /**
     * Constructs a tab plane.
     *
     * @param editor {DescriptionEditor} the parent description editor
     */
    constructor(editor) {
        this.editor = editor;
        this.prefix = `${this.editor.prefix}-${this.tabId()}`;
        this.#tab = document.getElementById(`${this.prefix}-tab`);
        this.#page = document.getElementById(`${this.prefix}-page`);
        this.#tab.onclick = () => this.switchToMe();
    }

    /**
     * The tab ID
     *
     * @return {string}
     * @abstract
     */
    tabId() { throw new Error("Method not implemented.") };

    /**
     * Resets the tab plane input.
     *
     * @abstract
     */
    reset() { throw new Error("Method not implemented."); }

    /**
     * Populates the tab plane with the description input.
     *
     * @return {boolean} true if the description input matches this tab, or false otherwise
     * @abstract
     */
    populate() { throw new Error("Method not implemented."); }

    /**
     * Validates the input in the tab plane.
     *
     * @return {boolean} true if valid, or false otherwise
     * @abstract
     */
    validate() { throw new Error("Method not implemented."); }

    /**
     * Switches to the tab plane.
     *
     */
    switchToMe() {
        for (const tabPlane of Object.values(this.editor.tabPlanes)) {
            tabPlane.#tab.classList.remove("active")
            tabPlane.#tab.ariaCurrent = "false";
            tabPlane.#page.classList.add("d-none");
            tabPlane.#page.ariaCurrent = "false";
        }
        this.#tab.classList.add("active");
        this.#tab.ariaCurrent = "page";
        this.#page.classList.remove("d-none");
        this.#page.ariaCurrent = "page";
        this.editor.currentTab = this;
    }
}

/**
 * A tag plane with selectable tags.
 *
 * @abstract
 * @private
 */
class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {

    /**
     * The tag input
     * @type {HTMLInputElement}
     */
    tag;

    /**
     * The error message for the tag input
     * @type {HTMLDivElement}
     */
    tagError;

    /**
     * The tag buttons
     * @type {HTMLButtonElement[]}
     */
    tagButtons;

    /**
     * Constructs a tab plane.
     *
     * @param editor {DescriptionEditor} the parent description editor
     * @override
     */
    constructor(editor) {
        super(editor);
        this.tag = document.getElementById(`${this.prefix}-tag`);
        this.tagError = document.getElementById(`${this.prefix}-tag-error`);
        // noinspection JSValidateTypes
        this.tagButtons = Array.from(document.getElementsByClassName(`${this.prefix}-btn-tag`));
        this.initializeTagButtons();
        this.tag.onchange = () => {
            this.onTagChange();
            this.updateDescription();
        };
    }

    /**
     * The callback when the tag input is changed
     *
     */
    onTagChange() {
        this.tag.value = this.tag.value.trim();
        let isMatched = false;
        for (const tagButton of this.tagButtons) {
            if (tagButton.dataset.value === this.tag.value) {
                tagButton.classList.remove("btn-outline-primary");
                tagButton.classList.add("btn-primary");
                this.editor.updateCurrentSuggestedAccounts(tagButton);
                isMatched = true;
            } else {
                tagButton.classList.remove("btn-primary");
                tagButton.classList.add("btn-outline-primary");
            }
        }
        if (!isMatched) {
            this.editor.clearSuggestedAccounts();
        }
        this.validateTag();
    }

    /**
     * Updates the description according to the input in the tab plane.
     *
     * @abstract
     */
    updateDescription() { throw new Error("Method not implemented."); }

    /**
     * Switches to the tab plane.
     *
     */
    switchToMe() {
        super.switchToMe();
        for (const tagButton of this.tagButtons) {
            if (tagButton.classList.contains("btn-primary")) {
                this.editor.updateCurrentSuggestedAccounts(tagButton);
                return;
            }
        }
        this.editor.clearSuggestedAccounts();
    }

    /**
     * Initializes the tag buttons.
     *
     */
    initializeTagButtons() {
        for (const tagButton of this.tagButtons) {
            tagButton.onclick = () => {
                for (const otherButton of this.tagButtons) {
                    otherButton.classList.remove("btn-primary");
                    otherButton.classList.add("btn-outline-primary");
                }
                tagButton.classList.remove("btn-outline-primary");
                tagButton.classList.add("btn-primary");
                this.tag.value = tagButton.dataset.value;
                this.editor.updateCurrentSuggestedAccounts(tagButton);
                this.updateDescription();
            };
        }
    }

    /**
     * Validates the tag input.
     *
     * @return {boolean} true if valid, or false otherwise
     */
    validateTag() {
        this.tag.value = this.tag.value.trim();
        this.tag.classList.remove("is-invalid");
        this.tagError.innerText = "";
        return true;
    }

    /**
     * Validates a required field.
     *
     * @param field {HTMLInputElement} the input field
     * @param errorContainer {HTMLDivElement} the error message container
     * @param errorMessage {string} the error message
     * @return {boolean} true if valid, or false otherwise
     */
    validateRequiredField(field, errorContainer, errorMessage) {
        field.value = field.value.trim();
        if (field.value === "") {
            field.classList.add("is-invalid");
            errorContainer.innerText = errorMessage;
            return false;
        }
        field.classList.remove("is-invalid");
        errorContainer.innerText = "";
        return true;
    }

    /**
     * Resets the tab plane input.
     *
     * @override
     */
    reset() {
        this.tag.value = "";
        this.tag.classList.remove("is-invalid");
        this.tagError.innerText = "";
        for (const tagButton of this.tagButtons) {
            tagButton.classList.remove("btn-primary");
            tagButton.classList.add("btn-outline-primary");
        }
    }
}

/**
 * The general tag tab plane.
 *
 * @private
 */
class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {

    /**
     * The tab ID
     *
     * @return {string}
     * @abstract
     */
    tabId() {
        return "general";
    };

    /**
     * Updates the description according to the input in the tab plane.
     *
     * @override
     */
    updateDescription() {
        const pos = this.editor.description.indexOf("—");
        const prefix = this.tag.value === ""? "": `${this.tag.value}—`;
        if (pos === -1) {
            this.editor.description = `${prefix}${this.editor.description}`;
        } else {
            this.editor.description = `${prefix}${this.editor.description.substring(pos + 1)}`;
        }
    }

    /**
     * Populates the tab plane with the description input.
     *
     * @return {boolean} true if the description input matches this tab, or false otherwise
     * @override
     */
    populate() {
        const found = this.editor.description.match(/^([^—]+)—/);
        if (found === null) {
            return false;
        }
        if (this.tag.value !== found[1]) {
            this.tag.value = found[1];
            this.onTagChange();
        }
        this.switchToMe();
        return true;
    }

    /**
     * Validates the input in the tab plane.
     *
     * @return {boolean} true if valid, or false otherwise
     */
    validate() {
        return this.validateTag();
    }
}

/**
 * The general trip tab plane.
 *
 * @private
 */
class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {

    /**
     * The origin
     * @type {HTMLInputElement}
     */
    #from;

    /**
     * The error of the origin
     * @type {HTMLDivElement}
     */
    #fromError;

    /**
     * The destination
     * @type {HTMLInputElement}
     */
    #to;

    /**
     * The error of the destination
     * @type {HTMLDivElement}
     */
    #toError;

    /**
     * The direction buttons
     * @type {HTMLButtonElement[]}
     */
    #directionButtons;

    /**
     * Constructs a tab plane.
     *
     * @param editor {DescriptionEditor} the parent description editor
     * @override
     */
    constructor(editor) {
        super(editor);
        this.#from = document.getElementById(`${this.prefix}-from`);
        this.#fromError = document.getElementById(`${this.prefix}-from-error`);
        this.#to = document.getElementById(`${this.prefix}-to`);
        this.#toError = document.getElementById(`${this.prefix}-to-error`)
        // noinspection JSValidateTypes
        this.#directionButtons = Array.from(document.getElementsByClassName(`${this.prefix}-direction`));
        this.#from.onchange = () => {
            this.#from.value = this.#from.value.trim();
            this.updateDescription();
            this.validateFrom();
        };
        for (const directionButton of this.#directionButtons) {
            directionButton.onclick = () => {
                for (const otherButton of this.#directionButtons) {
                    otherButton.classList.remove("btn-primary");
                    otherButton.classList.add("btn-outline-primary");
                }
                directionButton.classList.remove("btn-outline-primary");
                directionButton.classList.add("btn-primary");
                this.updateDescription();
            };
        }
        this.#to.onchange = () => {
            this.#to.value = this.#to.value.trim();
            this.updateDescription();
            this.validateTo();
        };
    }

    /**
     * The tab ID
     *
     * @return {string}
     * @abstract
     */
    tabId() {
        return "travel";
    };

    /**
     * Updates the description according to the input in the tab plane.
     *
     * @override
     */
    updateDescription() {
        let direction;
        for (const directionButton of this.#directionButtons) {
            if (directionButton.classList.contains("btn-primary")) {
                direction = directionButton.dataset.arrow;
                break;
            }
        }
        this.editor.description = `${this.tag.value}—${this.#from.value}${direction}${this.#to.value}`;
    }

    /**
     * Resets the tab plane input.
     *
     * @override
     */
    reset() {
        super.reset();
        this.#from.value = "";
        this.#from.classList.remove("is-invalid");
        this.#fromError.innerText = "";
        for (const directionButton of this.#directionButtons) {
            if (directionButton.classList.contains("accounting-default")) {
                directionButton.classList.remove("btn-outline-primary");
                directionButton.classList.add("btn-primary");
            } else {
                directionButton.classList.add("btn-outline-primary");
                directionButton.classList.remove("btn-primary");
            }
        }
        this.#to.value = "";
        this.#to.classList.remove("is-invalid");
        this.#toError.innerText = "";
    }

    /**
     * Populates the tab plane with the description input.
     *
     * @return {boolean} true if the description input matches this tab, or false otherwise
     * @override
     */
    populate() {
        const found = this.editor.description.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
        if (found === null) {
            return false;
        }
        if (this.tag.value !== found[1]) {
            this.tag.value = found[1];
            this.onTagChange();
        }
        this.#from.value = found[2];
        for (const directionButton of this.#directionButtons) {
            if (directionButton.dataset.arrow === found[3]) {
                directionButton.classList.remove("btn-outline-primary");
                directionButton.classList.add("btn-primary");
            } else {
                directionButton.classList.add("btn-outline-primary");
                directionButton.classList.remove("btn-primary");
            }
        }
        this.#to.value = found[4];
        this.switchToMe();
        return true;
    }

    /**
     * Validates the input in the tab plane.
     *
     * @return {boolean} true if valid, or false otherwise
     * @override
     */
    validate() {
        let isValid = true;
        isValid = this.validateTag() && isValid;
        isValid = this.validateFrom() && isValid;
        isValid = this.validateTo() && isValid;
        return isValid;
    }

    /**
     * Validates the tag input.
     *
     * @return {boolean} true if valid, or false otherwise
     * @override
     */
    validateTag() {
        return this.validateRequiredField(this.tag, this.tagError, A_("Please fill in the tag."));
    }

    /**
     * Validates the origin.
     *
     * @return {boolean} true if valid, or false otherwise
     * @override
     */
    validateFrom() {
        return this.validateRequiredField(this.#from, this.#fromError, A_("Please fill in the origin."));
    }

    /**
     * Validates the destination.
     *
     * @return {boolean} true if valid, or false otherwise
     * @override
     */
    validateTo() {
        return this.validateRequiredField(this.#to, this.#toError, A_("Please fill in the destination."));
    }
}

/**
 * The bus trip tab plane.
 *
 * @private
 */
class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {

    /**
     * The route
     * @type {HTMLInputElement}
     */
    #route;

    /**
     * The error of the route
     * @type {HTMLDivElement}
     */
    #routeError;

    /**
     * The origin
     * @type {HTMLInputElement}
     */
    #from;

    /**
     * The error of the origin
     * @type {HTMLDivElement}
     */
    #fromError;

    /**
     * The destination
     * @type {HTMLInputElement}
     */
    #to;

    /**
     * The error of the destination
     * @type {HTMLDivElement}
     */
    #toError;

    /**
     * Constructs a tab plane.
     *
     * @param editor {DescriptionEditor} the parent description editor
     * @override
     */
    constructor(editor) {
        super(editor);
        this.#route = document.getElementById(`${this.prefix}-route`);
        this.#routeError = document.getElementById(`${this.prefix}-route-error`);
        this.#from = document.getElementById(`${this.prefix}-from`);
        this.#fromError = document.getElementById(`${this.prefix}-from-error`);
        this.#to = document.getElementById(`${this.prefix}-to`);
        this.#toError = document.getElementById(`${this.prefix}-to-error`)
        this.#route.onchange = () => {
            this.#route.value = this.#route.value.trim();
            this.updateDescription();
            this.validateRoute();
        };
        this.#from.onchange = () => {
            this.#from.value = this.#from.value.trim();
            this.updateDescription();
            this.validateFrom();
        };
        this.#to.onchange = () => {
            this.#to.value = this.#to.value.trim();
            this.updateDescription();
            this.validateTo();
        };
    }

    /**
     * The tab ID
     *
     * @return {string}
     * @abstract
     */
    tabId() {
        return "bus";
    };

    /**
     * Updates the description according to the input in the tab plane.
     *
     * @override
     */
    updateDescription() {
        this.editor.description = `${this.tag.value}—${this.#route.value}—${this.#from.value}→${this.#to.value}`;
    }

    /**
     * Resets the tab plane input.
     *
     * @override
     */
    reset() {
        super.reset();
        this.#route.value = "";
        this.#route.classList.remove("is-invalid");
        this.#routeError.innerText = "";
        this.#from.value = "";
        this.#from.classList.remove("is-invalid");
        this.#fromError.innerText = "";
        this.#to.value = "";
        this.#to.classList.remove("is-invalid");
        this.#toError.innerText = "";
    }

    /**
     * Populates the tab plane with the description input.
     *
     * @return {boolean} true if the description input matches this tab, or false otherwise
     * @override
     */
    populate() {
        const found = this.editor.description.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
        if (found === null) {
            return false;
        }
        if (this.tag.value !== found[1]) {
            this.tag.value = found[1];
            this.onTagChange();
        }
        this.#route.value = found[2];
        this.#from.value = found[3];
        this.#to.value = found[4];
        this.switchToMe();
        return true;
    }

    /**
     * Validates the input in the tab plane.
     *
     * @return {boolean} true if valid, or false otherwise
     */
    validate() {
        let isValid = true;
        isValid = this.validateTag() && isValid;
        isValid = this.validateRoute() && isValid;
        isValid = this.validateFrom() && isValid;
        isValid = this.validateTo() && isValid;
        return isValid;
    }

    /**
     * Validates the tag input.
     *
     * @return {boolean} true if valid, or false otherwise
     * @override
     */
    validateTag() {
        return this.validateRequiredField(this.tag, this.tagError, A_("Please fill in the tag."));
    }

    /**
     * Validates the route.
     *
     * @return {boolean} true if valid, or false otherwise
     * @override
     */
    validateRoute() {
        return this.validateRequiredField(this.#route, this.#routeError, A_("Please fill in the route."));
    }

    /**
     * Validates the origin.
     *
     * @return {boolean} true if valid, or false otherwise
     * @override
     */
    validateFrom() {
        return this.validateRequiredField(this.#from, this.#fromError, A_("Please fill in the origin."));
    }

    /**
     * Validates the destination.
     *
     * @return {boolean} true if valid, or false otherwise
     * @override
     */
    validateTo() {
        return this.validateRequiredField(this.#to, this.#toError, A_("Please fill in the destination."));
    }
}

/**
 * The recurring transaction tab plane.
 *
 * @private
 */
class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {

    /**
     * The month names
     * @type {string[]}
     */
    #monthNames;

    /**
     * The buttons of the recurring items
     * @type {HTMLButtonElement[]}
     */
    #itemButtons;

    /**
     * Constructs a tab plane.
     *
     * @param editor {DescriptionEditor} the parent description editor
     * @override
     */
    constructor(editor) {
        super(editor);
        this.#monthNames = [
            "",
            A_("January"), A_("February"), A_("March"), A_("April"),
            A_("May"), A_("June"), A_("July"), A_("August"),
            A_("September"), A_("October"), A_("November"), A_("December"),
        ];
        // noinspection JSValidateTypes
        this.#itemButtons = Array.from(document.getElementsByClassName(`${this.prefix}-item`));
        for (const itemButton of this.#itemButtons) {
            itemButton.onclick = () => {
                this.reset();
                itemButton.classList.add("btn-primary");
                itemButton.classList.remove("btn-outline-primary");
                this.editor.description = this.#getDescription(itemButton);
                this.editor.updateCurrentSuggestedAccounts(itemButton);
            };
        }
    }

    /**
     * Returns the description for a recurring item.
     *
     * @param itemButton {HTMLButtonElement} the recurring item
     * @return {string} the description of the recurring item
     */
    #getDescription(itemButton) {
        const today = new Date(this.editor.lineItemEditor.form.date);
        const thisMonth = today.getMonth() + 1;
        const lastMonth = (thisMonth + 10) % 12 + 1;
        const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1);
        const lastBimonthlyTo = ((thisMonth + thisMonth % 2 + 9) % 12 + 1);
        return itemButton.dataset.template
            .replaceAll("{this_month_number}", String(thisMonth))
            .replaceAll("{this_month_name}", this.#monthNames[thisMonth])
            .replaceAll("{last_month_number}", String(lastMonth))
            .replaceAll("{last_month_name}", this.#monthNames[lastMonth])
            .replaceAll("{last_bimonthly_number}", `${String(lastBimonthlyFrom)}–${String(lastBimonthlyTo)}`)
            .replaceAll("{last_bimonthly_name}", `${this.#monthNames[lastBimonthlyFrom]}–${this.#monthNames[lastBimonthlyTo]}`);
    }

    /**
     * The tab ID
     *
     * @return {string}
     * @abstract
     */
    tabId() {
        return "recurring";
    };

    /**
     * Resets the tab plane input.
     *
     * @override
     */
    reset() {
        for (const itemButton of this.#itemButtons) {
            itemButton.classList.remove("btn-primary");
            itemButton.classList.add("btn-outline-primary");
        }
    }

    /**
     * Populates the tab plane with the description input.
     *
     * @return {boolean} true if the description input matches this tab, or false otherwise
     * @override
     */
    populate() {
        for (const itemButton of this.#itemButtons) {
            if (this.#getDescription(itemButton) === this.editor.description) {
                itemButton.classList.add("btn-primary");
                itemButton.classList.remove("btn-outline-primary");
                this.switchToMe();
                return true;
            }
        }
        return false;
    }

    /**
     * Switches to the tab plane.
     *
     */
    switchToMe() {
        super.switchToMe();
        for (const itemButton of this.#itemButtons) {
            if (itemButton.classList.contains("btn-primary")) {
                this.editor.updateCurrentSuggestedAccounts(itemButton);
                return;
            }
        }
        this.editor.clearSuggestedAccounts();
    }

    /**
     * Validates the input in the tab plane.
     *
     * @return {boolean} true if valid, or false otherwise
     * @override
     */
    validate() {
        return true;
    }
}

/**
 * The annotation tab plane.
 *
 * @private
 */
class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {

    /**
     * Constructs a tab plane.
     *
     * @param editor {DescriptionEditor} the parent description editor
     * @override
     */
    constructor(editor) {
        super(editor);
        this.editor.number.onchange = () => this.updateDescription();
        this.editor.note.onchange = () => {
            this.editor.note.value = this.editor.note.value.trim();
            this.updateDescription();
        };
    }

    /**
     * The tab ID
     *
     * @return {string}
     * @abstract
     */
    tabId() {
        return "annotation";
    };

    /**
     * Updates the description according to the input in the tab plane.
     *
     * @override
     */
    updateDescription() {
        const found = this.editor.description.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
        if (found !== null) {
            this.editor.description = found[1];
        }
        if (parseInt(this.editor.number.value) > 1) {
            this.editor.description = `${this.editor.description}×${this.editor.number.value}`;
        }
        if (this.editor.note.value !== "") {
            this.editor.description = `${this.editor.description}(${this.editor.note.value})`;
        }
    }

    /**
     * Resets the tab plane input.
     *
     * @override
     */
    reset() {
        this.editor.number.value = "";
        this.editor.note.value = "";
    }

    /**
     * Populates the tab plane with the description input.
     *
     * @return {boolean} true if the description input matches this tab, or false otherwise
     * @override
     */
    populate() {
        const found = this.editor.description.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
        this.editor.description = found[1];
        if (found[2] === undefined || parseInt(found[2]) === 1) {
            this.editor.number.value = "";
        } else {
            this.editor.number.value = found[2];
            this.editor.description = `${this.editor.description}×${this.editor.number.value}`;
        }
        if (found[3] === undefined) {
            this.editor.note.value = "";
        } else {
            this.editor.note.value = found[3];
            this.editor.description = `${this.editor.description}(${this.editor.note.value})`;
        }
        return true;
    }

    /**
     * Validates the input in the tab plane.
     *
     * @return {boolean} true if valid, or false otherwise
     * @override
     */
    validate() {
        return true;
    }
}
