package de.duehl.swing.ui.highlightingeditor;

/*
 * Copyright 2017 Christian Dühl. All rights reserved.
 *
 * This program is free software. You can redistribute it and/or
 * modify it under the same terms as perl:
 *
 * general:  http://dev.perl.org/licenses/
 * GPL:      http://dev.perl.org/licenses/gpl1.html
 * artistic: http://dev.perl.org/licenses/artistic.html
 */

import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.MouseAdapter;
import java.util.ArrayList;
import java.util.List;

import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.SwingUtilities;
import javax.swing.event.DocumentListener;
import javax.swing.filechooser.FileFilter;
import javax.swing.text.StyledDocument;

import de.duehl.basics.debug.Assure;
import de.duehl.basics.debug.DebugHelper;
import de.duehl.basics.history.History;
import de.duehl.basics.history.HistoryHelper;
import de.duehl.basics.io.Charset;
import de.duehl.swing.clipboard.Clipboard;
import de.duehl.swing.ui.GuiTools;
import de.duehl.swing.ui.dialogs.CharsetDialog;
import de.duehl.swing.ui.elements.TextLineNumber;
import de.duehl.swing.ui.elements.fontsize.FontSizeInteraction;
import de.duehl.swing.ui.tabs.ChangeStateDisplayer;
import de.duehl.swing.ui.highlightingeditor.buttonbar.ButtonBar;
import de.duehl.swing.ui.highlightingeditor.buttonbar.EditorForButtonBar;
import de.duehl.swing.ui.highlightingeditor.changestate.EditorChangeState;
import de.duehl.swing.ui.highlightingeditor.contextmenu.EditorForContextMenu;
import de.duehl.swing.ui.highlightingeditor.contextmenu.EditorPopupMenu;
import de.duehl.swing.ui.highlightingeditor.multiplereactor.MultipleChangeReactor;
import de.duehl.swing.ui.highlightingeditor.state.EditorState;
import de.duehl.swing.ui.highlightingeditor.state.EditorStateCopier;
import de.duehl.swing.ui.highlightingeditor.syntax.SyntaxHighlighting;
import de.duehl.swing.ui.highlightingeditor.textcomponent.ScrollingSuppressingTextPane;
import de.duehl.swing.ui.highlightingeditor.userinput.EditorForReactAfterUserInput;
import de.duehl.swing.ui.highlightingeditor.userinput.ReactAfterUserInput;

/**
 * Diese Klasse stellt einen bearbeitbaren Editor mit Syntax-Highlighting dar.
 *
 * @version 1.01     2017-12-19
 * @author Christian Dühl
 */

public class HighlightingEditor implements EditorForContextMenu, EditorForButtonBar,
        EditorForReactAfterUserInput, FontSizeInteraction {

    /*
     * TODO:
     *
     * - manchmal wird der Reiter beim Speichern nicht wieder schwarz, manchmal schon.
     *
     * - bei schnellem Wechseln Font/Größen wird der Reiter auch mal rot - seltsam.
     *
     * Vielleicht inzwischen durch weniger SwingUtilities#invokeLater() behoben?
     *
     * - Wird beim Ändern der Schriftgröße manchmal rot.
     *
     * - Bei großen Dateien dauert das Syntax-Highlighting. Da vielleicht eigene Splitpane auf
     *   eben diesem Editor? Dann muss aber doch im Hintergrund gehighlighted werden...
     *
     */

    private static final int MILLISECONDS_BEFORE_SYNTAX_HIGHLIGHTING = 900;

    public static final Charset DEFAULT_CHARSET = Charset.ISO_8859_1;
    private static final boolean SHOW_LINE_NUMBERS = true;

    private final static boolean DEBUG = true;

    /** Die veränderlichen logischen Daten des Editors, die bei Redo und Undo relevant sind. */
    private EditorState editorState;

    /** Hält die veränderlichen logischen Daten, also die  Zustände der History des Editors vor. */
    private final History<EditorState> history;

    /** Das zu verwendende Syntax-Highlighting. */
    private SyntaxHighlighting sytaxHighlighting;

    /**
     * Liste von Objekten, die den Status, ob ein Dokument geändert worden ist oder im
     * Originalzustand ist, anzeigen können.
     */
    private final List<ChangeStateDisplayer> changeStateDisplayers;

    /** Aktuelle Schriftgröße des Editors. */
    private int fontSize = 16;

    /** Führt das SyntaxHghlighting nach Usereingaben durch. */
    private final ReactAfterUserInput highlightAfterUserInput;

    private final MultipleChangeReactor multipleChangeReactor;

    /** Verfolgt, ob der Inhalt des Editors sich geändert hat. */
    private final EditorChangeState changeState;

    /* Gui-Elemente: */

    /** Der eigentliche Editor als erweiterte JTextPane. */
    protected final ScrollingSuppressingTextPane editor;
    private final JPanel panel;
    private final JScrollPane scroll;
    private final ButtonBar buttonBar;

    /**
     * Um mehrere Editoren (verteilt auf mehrere Fenster) danach fragen zu können, welcher von
     * ihnen zuletzt den Focus hatte (denn beim Klick auf das Menü hat keiner mehr den Fokus),
     * merken wir uns den aktiven Editor.
     */
    private static HighlightingEditor activeEditor;

    /** Konstruktor. */
    public HighlightingEditor() {
        editorState = new EditorState();
        history = new History<>(new EditorStateCopier());

        changeStateDisplayers = new ArrayList<>();
        panel = new JPanel();
        editor = createEditor();
        buttonBar = createButtonBar();
        scroll = createScroll();
        highlightAfterUserInput = createHighlightAfterUserInput();

        changeState = new EditorChangeState();
        addChangeStateDisplayer(changeState);

        multipleChangeReactor = createMultipleChangeReactor();

        createPopupMenu();
        initEditorBlank();
        populatePanel();

        showEditorIsActive();
        updateUndoRedo();
    }

    protected ScrollingSuppressingTextPane createEditor() {
        ScrollingSuppressingTextPane editor = createScrollingSuppressingTextPane();
        editor.setEditable(true);
        GuiTools.setFontSize(editor, fontSize);
        editor.addFocusListener(createFocusListener());

        return editor;
    }

    protected ScrollingSuppressingTextPane createScrollingSuppressingTextPane() {
        return new ScrollingSuppressingTextPane();
    }

    protected ButtonBar createButtonBar() {
        return new ButtonBar((EditorForButtonBar) this);
    }

    private JScrollPane createScroll() {
        JScrollPane scroll = new JScrollPane(editor);

        if (SHOW_LINE_NUMBERS) {
            TextLineNumber tln = new TextLineNumber(editor, 5);
            scroll.setRowHeaderView(tln);
        }

        return scroll;
    }

    protected ReactAfterUserInput createHighlightAfterUserInput() {
        return new ReactAfterUserInput(MILLISECONDS_BEFORE_SYNTAX_HIGHLIGHTING, this);
    }

    protected MultipleChangeReactor createMultipleChangeReactor() {
        MultipleChangeReactor multipleChangeReactor = new MultipleChangeReactor();
        multipleChangeReactor.addChangeReactor(highlightAfterUserInput); // highlighting
        multipleChangeReactor.addChangeReactor(editor);                  // scrolling
        multipleChangeReactor.addChangeReactor(changeState);             // track change state
        return multipleChangeReactor;
    }

    protected void createPopupMenu() {
        editor.setComponentPopupMenu(new EditorPopupMenu(this));
        /* Nicht in createEditor(), da der Editor dann schon initialisiert sein muss. */
    }

    private void initEditorBlank() {
        doNotReactOnChanges();
        SwingUtilities.invokeLater(() -> showAfterOpenFile(""));
    }

    private void populatePanel() {
        panel.setLayout(new BorderLayout());

        panel.add(buttonBar.getComponent(), BorderLayout.NORTH);
        panel.add(scroll, BorderLayout.CENTER);
    }

    /** Gibt an, ob der Inhalt des Editors geändert wurde. */
    public boolean isContentChanged() {
        return changeState.isChanged();
    }

    private void reactOnChanges() {
        multipleChangeReactor.reactOnChanges();
    }

    private void doNotReactOnChanges() {
        multipleChangeReactor.doNotReactOnChanges();
    }

    /** Fügt dem Editor einen DocumentListener hinzu. */
    @Override
    public void addDocumentListener(DocumentListener listener) {
        editor.getDocument().addDocumentListener(listener);
    }

    /** Setzt die zu verwendende Syntax-Hervorhebung, falls eine gewünscht wird. */
    public void useSyntaxHighlighting(SyntaxHighlighting sytaxHighlighting) {
        initSyntaxHighlighting(sytaxHighlighting);
        highlightText();
    }

    protected void initSyntaxHighlighting(SyntaxHighlighting sytaxHighlighting) {
        this.sytaxHighlighting = sytaxHighlighting;
        StyledDocument styledDocument = editor.getStyledDocument();
        sytaxHighlighting.initStyles(styledDocument);
    }

    /** Reagiert darauf, das vor einiger Zeit Benutzereingaben stattfanden. */
    @Override
    public void reactAfterUserInput() {
        updateUndoRedo();
        highlightText();
    }

    /** Führt die Auszeichnung der Syntax des Textes durch. */
    public void highlightText() {
        SwingUtilities.invokeLater(() -> highlightTextInEdt());
    }

    protected void highlightTextInEdt() {
        if (null != sytaxHighlighting && !editor.getText().isEmpty()) {
            reallyHighlightText();
        }
    }

    private synchronized void reallyHighlightText() {  // synchronized, da im EDT
        say("reallyHighlightText() start");
        doNotReactOnChanges();
        StyledDocument styledDocument = editor.getStyledDocument();
        String text = editor.getText();

        int caretPosition = editor.getCaretPosition();

        sytaxHighlighting.highlightText(text, styledDocument);

        editor.validate();
        String newText = editor.getText();
        Assure.isEqual(text, newText);

        afterSyntaxHighlightInEDT(caretPosition);
    }

    private void afterSyntaxHighlightInEDT(int caretPosition) {
        editor.setCaretPosition(caretPosition);
        editor.requestFocusInWindow(); // dadurch blinkt das Caret!
        reactOnChanges();
        say("reallyHighlightText() ende");
    }

    /**
     * Gibt die Komponente zum Einfügen in die Gui zurück Es muss keine ScrollPane darum gebastelt
     * werden, darum kümmert sich der Editor von alleine.
     */
    public Component getComponent() {
        return panel;
    }

    /** Setzt die gewünschte Größe des Editors. */
    public void setPreferredSize(Dimension preferredSize) {
        panel.setPreferredSize(preferredSize);
    }

    /** Zeigt an, dass das Dokument geändert worden ist. */
    @Override
    public void signChangedState() {
        say("verändert");
        showEditorIsActive();
        for (ChangeStateDisplayer changeStateDisplayer : changeStateDisplayers) {
            //SwingUtilities.invokeLater(() -> changeStateDisplayer.signChangedState());
            changeStateDisplayer.signChangedState();
        }
    }

    private void showEditorIsActive() {
        activeEditor = this;
    }

    /** Zeigt an, dass das Dokument im Originalzustand vorliegt. */
    protected void signUnchangedState() {
        say("nicht verändert");
        for (ChangeStateDisplayer changeStateDisplayer : changeStateDisplayers) {
            //SwingUtilities.invokeLater(() -> changeStateDisplayer.signUnchangedState());
            changeStateDisplayer.signUnchangedState();
        }
    }

    /**
     * Fügt einen Beobachter des Editor-Status hinzu, der anzeigen kann, ob ein Dokument geändert
     * worden ist oder im Originalzustand ist.
     */
    public void addChangeStateDisplayer(ChangeStateDisplayer changeStateDisplayer) {
        changeStateDisplayers.add(changeStateDisplayer);
    }

    /** Verwendet im Editor einen Monospace-Font. */
    public void useMonospaceFont() {
        GuiTools.setMonospacedFont(editor, fontSize);
    }

    /** Wählt den gesamten Text aus. */
    @Override
    public void selectAll() {
        editor.selectAll();
    }

    /** Gibt den selektierten Text zurück. */
    @Override
    public String getSelectedText() {
        return editor.getSelectedText();
    }

    /** Selection ausschneiden. */
    @Override
    public void cutSelection() {
        copySelection();
        editor.replaceSelection("");
        highlightAfterUserInput.startTimer();
    }

    /** Selection kopieren. */
    @Override
    public void copySelection() {
        String selection = editor.getSelectedText();
        if (null == selection || selection.isEmpty()) {
            return;
        }

        Clipboard.insert(selection);
    }

    /** Inhalt der Zwischenablage einfügen. */
    @Override
    public void insertSelection() {
        String content = Clipboard.getContent();
        if (null == content || content.isEmpty()) {
            return;
        }

        editor.replaceSelection(""); // damit man ausgewähltes ersetzt

        int position = editor.getCaretPosition();
        editor.setSelectionStart(position);
        editor.setSelectionEnd(position);
        editor.replaceSelection(content);

        highlightAfterUserInput.startTimer();
    }

    /** Fügt einen MouseListener hinzu, damit man erkennen kann, ob was selektiert ist. */
    @Override
    public void addMouseListener(MouseAdapter adapter) {
        editor.addMouseListener(adapter);
    }

    /** Erstellt ein neues, leeres Dokument. */
    @Override
    public void newFile() {
        editorState.newFile();
        editor.setText("");
        editor.setCaretPosition(0);
        signUnchangedState();
        updateDisplayName();
        updateUndoRedoFromScratch();
    }

    private void updateDisplayName() {
        for (ChangeStateDisplayer changeStateDisplayer : changeStateDisplayers) {
            //SwingUtilities.invokeLater(() -> changeStateDisplayer.updateName(createNameForDisplay()));
            changeStateDisplayer.updateName(editorState.createNameForDisplay());
        }
    }

    /** Lädt ein Dokument. */
    @Override
    public void openFile() {
        String filename = askUserAboutFileToOpen();
        if (!filename.isEmpty()) {
            Charset charset = askUserAboutCharset(filename);
            new Thread(() -> openFile(filename, charset)).start();
        }
    }

    private String askUserAboutFileToOpen() {
        String startDirectory = editorState.determineStartDirectory();
        FileFilter allFileFilter = GuiTools.createAllFileFilter();

        return GuiTools.openFile(panel, startDirectory, allFileFilter);
    }

    private Charset askUserAboutCharset(String newFilename) {
        CharsetDialog dialog = new CharsetDialog(panel.getLocation(), null, newFilename);
        dialog.setVisible(true);
        Charset charset = dialog.getSelectedCharset();
        return charset;
    }

    /**
     * Öffnet ein Dokument im Default-Charset.
     *
     * Hierbei wird davon aufgerufen, dass sich die aufrufende Seite darum kümmert, die grafische
     * Oberfläche vor dem Aufruf zu sperren, diese Methode in einem eigenen Thread aufzurufen, und
     * die grafische Oberfläche hinterher wieder freizugeben.
     */
    public void openFile(String filename) {
        openFile(filename, DEFAULT_CHARSET);
    }

    /**
     * Öffnet ein Dokument.
     *
     * Hierbei wird davon aufgerufen, dass sich die aufrufende Seite darum kümmert, die grafische
     * Oberfläche vor dem Aufruf zu sperren, diese Methode in einem eigenen Thread aufzurufen, und
     * die grafische Oberfläche hinterher wieder freizugeben.
     */
    public void openFile(String filename, Charset charset) {
        say("openFile  - filename = " + filename);
        doNotReactOnChanges();

        String text = editorState.openFile(filename, charset);
        SwingUtilities.invokeLater(() -> showAfterOpenFile(text));
    }

    private void showAfterOpenFile(String text) {
        editor.setText(text);
        editor.setCaretPosition(0);
        say("before highlight");
        highlightTextInEdt();
        say("after highlight");
        reactOnChanges();
        signUnchangedState();
        updateDisplayName();
        updateUndoRedoFromScratch();
        validate();
    }

    /** Speichert das Dokument. */
    @Override
    public void saveFile() {
        if (editorState.isFilenameEmpty()) {
            saveFileAs();
        }
        else {
            saveUnderFilename();
        }
    }

    /** Speichert das Dokument unter einem anderen Namen. */
    @Override
    public void saveFileAs() {
        String filename = askUserAboutFilenameToSave();
        if (!filename.isEmpty()) {
            editorState.setFilename(filename);
            saveUnderFilename();
        }
    }

    private String askUserAboutFilenameToSave() {
        String title = "Bitte Dateinamen eingeben.";
        String startDirectory = editorState.determineStartDirectory();
        FileFilter allFileFilter = GuiTools.createAllFileFilter();
        String barename = editorState.getBarename();

        return GuiTools.saveFileAsWithTitle(title, panel, startDirectory, allFileFilter, barename);
    }

    private void saveUnderFilename() {
        editorState.saveUnderFilename(editor.getText());
        signUnchangedState();
        updateUndoRedoFromScratch(); // sonst wechselt man vielleicht noch in anderen Dateinamen...
                                     // ist das nicht gewollt, zwischen "save" und "save as"
                                     // unterscheiden.
        editor.requestFocusInWindow();
    }

    /** Ermittelt die aktuelle Schriftgröße. */
    @Override
    public int getFontSize() {
        return fontSize;
    }

    /** Setzt die Schriftgröße. */
    @Override
    public void setFontSize(int fontSize) {
        this.fontSize = fontSize;
        handleFontSizeChange(fontSize);
        buttonBar.refreshFontSize(); // da von außen aufgerufen, muss das hier gemacht werden.
    }

    private void handleFontSizeChange(int fontSize) {
        doNotReactOnChanges();
        GuiTools.setFontSize(editor, fontSize);
        reactOnChanges();

        /*
         * Sonst wird erst nur der farbige Text größer oder kleiner dargestellt.
         * Ein editor.validate(); oder ein editor.repaint(); reicht dafür seltsamer Weise nicht aus.
         */
        highlightTextInEdt();
    }

    /** Erhöht die Schriftgröße. */
    @Override
    public void incrementFontSize() {
        ++fontSize;
        handleFontSizeChange(fontSize);
    }

    /** Verringert die Schriftgröße. */
    @Override
    public void decrementFontSize() {
        if (fontSize > 1) {
            --fontSize;
            handleFontSizeChange(fontSize);
        }
    }

    /** Beendet den Timer der auf Eingaben des Benutzers wartet. */
    public void cancelTimer() {
        highlightAfterUserInput.cancelTimer();
    }

    /**
     * Name der Datei, die im Editor angezeigt wird. Der leere String, wenn die Datei bislang
     * keinen Namen hat.
     */
    public String getFilename() {
        return editorState.getFilename();
    }

    private void validate() {
        editor.validate();
        scroll.validate();
    }

    public void requestFocusInWindow() {
        say("requestFocusInWindow()");
        editor.requestFocusInWindow();
    }

    private FocusListener createFocusListener() {
        return new FocusListener() {
            @Override
            public void focusLost(FocusEvent e) {
                say("Fokus lost");
            }

            @Override
            public void focusGained(FocusEvent e) {
                say("Fokus gained");
                showEditorIsActive();
                checkFileOnDisk();
            }
        };
    }

    /** Setter für den Namen der Datei, die im Editor angezeigt wird. */
    public void setFilenameButDoNotDoAnythingElse(String filename) {
        editorState.setFilename(filename);
    }

    /** Getter für den aktuellen Text des Editors. */
    public String getText() {
        return editor.getText();
    }

    /** Setter für den aktuellen Text des Editors. */
    public void setText(String text) {
        doNotReactOnChanges();
        showAfterOpenFile(text);
        signChangedState(); // da davor in showAfterOpenFile auf unchanged!
    }

    public void showEnd() {
        GuiTools.scrollScrollbarToMaximumLater(scroll);
    }

    public static HighlightingEditor getActiveEditor() {
        return activeEditor;
    }

    /** Liest die Datei erneut ein. Achtung: Ggf. vorhandene Änderungen gehen verloren. */
    public void reload() {
        String filename = editorState.getFilename();
        say("Lade Datei " + filename  + " erneut.");

        Charset charset = editorState.getCharset();
        openFile(filename, charset);
    }

    /** Gibt die Position des Cursors zurück. */
    public int getCaretPosition() {
        return editor.getCaretPosition();
    }

    /** Setzt die Position des Cursors. */
    public void setCaretPosition(int position) {
        editor.setCaretPosition(position);
        editor.requestFocusInWindow();
    }

    /** Setzt den markierten Bereich. */
    public void select(int selectionStart, int selectionEnd) {
        editor.select(selectionStart, selectionEnd);
        editor.requestFocusInWindow();
    }

    protected final void disableWatchFileOnDiskChange() {
        editorState.disableWatchFileOnDiskChange();
    }

    private void checkFileOnDisk() {
        if (editorState.doesFileNotExistAnymore()) {
            boolean keepFile = GuiTools.askUser(panel, "Hinweis", "<html>"
                    + "Die Datei wurde auf dem Dateisystem verschoben oder gelöscht. "
                    + "Wollen Sie sie trotzdem im Editor behalten?"
                    + "</html>");
            if (keepFile) {
                editorState.notLoaded();
            }
            else {
                newFile();
            }
        }
        else if (editorState.hasFileChangedOnDisk()) {
            boolean loadFile = GuiTools.askUser(panel, "Hinweis", "<html>"
                    + "Die Datei wurde auf dem Dateisystem geändert. "
                    + "Soll sie neu geladen werden?"
                    + "</html>");
            if (loadFile) {
                reload();
            }
        }
    }

    /** Entfernt alles bisheriges aus der History und sichert den aktuellen Stand in der History. */
    private void updateUndoRedoFromScratch() {
        history.clear();
        updateUndoRedo();
    }

    /** Sichert den aktuellen Stand in der History. */
    private void updateUndoRedo() {
        editorState.setTextAndCaretPosition(editor.getText(), editor.getCaretPosition());
        history.add(editorState);
        checkEnableHistory();
    }

    /** Überprüft das Anzeigen der undo- und redo-Schalter. */
    private void checkEnableHistory() {
        HistoryHelper.checkEnableHistory(history, buttonBar);
    }

    /** Einen Schritt in der Änderunghistorie zurück. */
    @Override
    public void undo() {
        say("undo");
        if (history.hasPrevious()) {
            EditorState previousEditorState = history.getPrevious();
            showUndoOrRedoEditorState(previousEditorState);
        }
    }

    /** Einen Schritt in der Änderunghistorie vorwärts. */
    @Override
    public void redo() {
        say("redo");
        if (history.hasNext()) {
            EditorState nextEditorState = history.getNext();
            showUndoOrRedoEditorState(nextEditorState);
        }
    }

    private void showUndoOrRedoEditorState(EditorState newEditorState) {
        editorState = new EditorState(newEditorState);
        checkEnableHistory();
        updateDisplayName(); // falls anderer Dateiname (darf eigentlich nicht mehr passieren)

        editor.requestFocusInWindow(); // da der betätigte Button den Focus hat.

        doNotReactOnChanges();
        editor.setText(editorState.getText());
        say("caretPosition = " + editorState.getCaretPosition());
        editor.setCaretPosition(editorState.getCaretPosition());
        reactOnChanges();
        highlightText();
    }

    protected final void say(String message) {
        if (DEBUG) {
            String filename = getFilename();
            String title = "(" + (null == filename ? "unknown" : filename) + ") - ";
            DebugHelper.sayWithClassAndMethodAndTime(title + message);
        }
    }

    @Override
    public String toString() {
        return "HighlightingEditor [filename=" + getFilename() + "]";
    }

}
