/*
 * Copyright (c) 2002-2020, the original author(s).
 *
 * This software is distributable under the BSD license. See the terms of the
 * BSD license in the documentation provided with this software.
 *
 * https://opensource.org/licenses/BSD-3-Clause
 */
package org.jline.utils;

import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.jline.terminal.Terminal;
import org.jline.utils.InfoCmp.Capability;

/**
 * Handle display and visual cursor.
 *
 * @author <a href="mailto:gnodet@gmail.com">Guillaume Nodet</a>
 */
public class Display {

    protected final Terminal terminal;
    protected final boolean fullScreen;
    protected final Map<Capability, Integer> cost = new HashMap<>();
    protected final boolean canScroll;
    protected final boolean wrapAtEol;
    protected final boolean delayedWrapAtEol;
    protected final boolean cursorDownIsNewLine;
    protected List<AttributedString> oldLines = Collections.emptyList();
    protected int cursorPos;
    protected int columns;
    protected int columns1; // columns+1
    protected int rows;
    protected boolean reset;
    protected boolean delayLineWrap;

    @SuppressWarnings("this-escape")
    public Display(Terminal terminal, boolean fullscreen) {
        this.terminal = terminal;
        this.fullScreen = fullscreen;

        this.canScroll = can(Capability.insert_line, Capability.parm_insert_line)
                && can(Capability.delete_line, Capability.parm_delete_line);
        this.wrapAtEol = terminal.getBooleanCapability(Capability.auto_right_margin);
        this.delayedWrapAtEol = this.wrapAtEol && terminal.getBooleanCapability(Capability.eat_newline_glitch);
        this.cursorDownIsNewLine = "\n".equals(Curses.tputs(terminal.getStringCapability(Capability.cursor_down)));
    }

    private static int[] longestCommon(List<AttributedString> l1, List<AttributedString> l2) {
        int start1 = 0;
        int start2 = 0;
        int max = 0;
        for (int i = 0; i < l1.size(); i++) {
            for (int j = 0; j < l2.size(); j++) {
                int x = 0;
                while (Objects.equals(l1.get(i + x), l2.get(j + x))) {
                    x++;
                    if (((i + x) >= l1.size()) || ((j + x) >= l2.size())) break;
                }
                if (x > max) {
                    max = x;
                    start1 = i;
                    start2 = j;
                }
            }
        }
        return max != 0 ? new int[]{start1, start2, max} : null;
    }

    /**
     * If cursor is at right margin, don't wrap immediately.
     * See <code>org.jline.reader.LineReader.Option#DELAY_LINE_WRAP</code>.
     *
     * @return <code>true</code> if line wrap is delayed, <code>false</code> otherwise
     */
    public boolean delayLineWrap() {
        return delayLineWrap;
    }

    public void setDelayLineWrap(boolean v) {
        delayLineWrap = v;
    }

    public void resize(int rows, int columns) {
        if (rows == 0 || columns == 0) {
            columns = Integer.MAX_VALUE - 1;
            rows = 1;
        }
        if (this.rows != rows || this.columns != columns) {
            this.rows = rows;
            this.columns = columns;
            this.columns1 = columns + 1;
            oldLines = AttributedString.join(AttributedString.EMPTY, oldLines)
                    .columnSplitLength(columns, true, delayLineWrap());
        }
    }

    public void reset() {
        oldLines = Collections.emptyList();
    }

    /**
     * Clears the whole screen.
     * Use this method only when using full-screen / application mode.
     */
    public void clear() {
        if (fullScreen) {
            reset = true;
        }
    }

    public void updateAnsi(List<String> newLines, int targetCursorPos) {
        update(newLines.stream().map(AttributedString::fromAnsi).collect(Collectors.toList()), targetCursorPos);
    }

    /**
     * Update the display according to the new lines and flushes the output.
     *
     * @param newLines        the lines to display
     * @param targetCursorPos desired cursor position - see Size.cursorPos.
     */
    public void update(List<AttributedString> newLines, int targetCursorPos) {
        update(newLines, targetCursorPos, true);
    }

    /**
     * Update the display according to the new lines.
     *
     * @param newLines        the lines to display
     * @param targetCursorPos desired cursor position - see Size.cursorPos.
     * @param flush           whether the output should be flushed or not
     */
    public void update(List<AttributedString> newLines, int targetCursorPos, boolean flush) {
        if (reset) {
            terminal.puts(Capability.clear_screen);
            oldLines.clear();
            cursorPos = 0;
            reset = false;
        }

        // If dumb display, get rid of ansi sequences now
        Integer cols = terminal.getNumericCapability(Capability.max_colors);
        if (cols == null || cols < 8) {
            newLines = newLines.stream()
                    .map(s -> new AttributedString(s.toString()))
                    .collect(Collectors.toList());
        }

        // Detect scrolling
        if ((fullScreen || newLines.size() >= rows) && newLines.size() == oldLines.size() && canScroll) {
            int nbHeaders = 0;
            int nbFooters = 0;
            // Find common headers and footers
            int l = newLines.size();
            while (nbHeaders < l && Objects.equals(newLines.get(nbHeaders), oldLines.get(nbHeaders))) {
                nbHeaders++;
            }
            while (nbFooters < l - nbHeaders - 1
                    && Objects.equals(
                    newLines.get(newLines.size() - nbFooters - 1),
                    oldLines.get(oldLines.size() - nbFooters - 1))) {
                nbFooters++;
            }
            List<AttributedString> o1 = newLines.subList(nbHeaders, newLines.size() - nbFooters);
            List<AttributedString> o2 = oldLines.subList(nbHeaders, oldLines.size() - nbFooters);
            int[] common = longestCommon(o1, o2);
            if (common != null) {
                int s1 = common[0];
                int s2 = common[1];
                int sl = common[2];
                if (sl > 1 && s1 < s2) {
                    moveVisualCursorTo((nbHeaders + s1) * columns1);
                    int nb = s2 - s1;
                    deleteLines(nb);
                    for (int i = 0; i < nb; i++) {
                        oldLines.remove(nbHeaders + s1);
                    }
                    if (nbFooters > 0) {
                        moveVisualCursorTo((nbHeaders + s1 + sl) * columns1);
                        insertLines(nb);
                        for (int i = 0; i < nb; i++) {
                            oldLines.add(nbHeaders + s1 + sl, new AttributedString(""));
                        }
                    }
                } else if (sl > 1 && s1 > s2) {
                    int nb = s1 - s2;
                    if (nbFooters > 0) {
                        moveVisualCursorTo((nbHeaders + s2 + sl) * columns1);
                        deleteLines(nb);
                        for (int i = 0; i < nb; i++) {
                            oldLines.remove(nbHeaders + s2 + sl);
                        }
                    }
                    moveVisualCursorTo((nbHeaders + s2) * columns1);
                    insertLines(nb);
                    for (int i = 0; i < nb; i++) {
                        oldLines.add(nbHeaders + s2, new AttributedString(""));
                    }
                }
            }
        }

        int lineIndex = 0;
        int currentPos = 0;
        int numLines = Math.min(rows, Math.max(oldLines.size(), newLines.size()));
        boolean wrapNeeded = false;
        while (lineIndex < numLines) {
            AttributedString oldLine = lineIndex < oldLines.size() ? oldLines.get(lineIndex) : AttributedString.NEWLINE;
            AttributedString newLine = lineIndex < newLines.size() ? newLines.get(lineIndex) : AttributedString.NEWLINE;
            currentPos = lineIndex * columns1;
            int curCol = currentPos;
            int oldLength = oldLine.length();
            int newLength = newLine.length();
            boolean oldNL = oldLength > 0 && oldLine.charAt(oldLength - 1) == '\n';
            boolean newNL = newLength > 0 && newLine.charAt(newLength - 1) == '\n';
            if (oldNL) {
                oldLength--;
                oldLine = oldLine.substring(0, oldLength);
            }
            if (newNL) {
                newLength--;
                newLine = newLine.substring(0, newLength);
            }
            if (wrapNeeded && lineIndex == (cursorPos + 1) / columns1 && lineIndex < newLines.size()) {
                // move from right margin to next line's left margin
                cursorPos++;
                if (newLength == 0 || newLine.isHidden(0)) {
                    // go to next line column zero
                    rawPrint(' ');
                    terminal.puts(Capability.key_backspace);
                } else {
                    AttributedString firstChar = newLine.substring(0, 1);
                    // go to next line column one
                    rawPrint(firstChar);
                    cursorPos += firstChar.columnLength(); // normally 1
                    newLine = newLine.substring(1, newLength);
                    newLength--;
                    if (oldLength > 0) {
                        oldLine = oldLine.substring(1, oldLength);
                        oldLength--;
                    }
                    currentPos = cursorPos;
                }
            }
            List<DiffHelper.Diff> diffs = DiffHelper.diff(oldLine, newLine);
            boolean ident = true;
            boolean cleared = false;
            for (int i = 0; i < diffs.size(); i++) {
                DiffHelper.Diff diff = diffs.get(i);
                int width = diff.text.columnLength();
                switch (diff.operation) {
                    case EQUAL:
                        if (!ident) {
                            cursorPos = moveVisualCursorTo(currentPos);
                            rawPrint(diff.text);
                            cursorPos += width;
                            currentPos = cursorPos;
                        } else {
                            currentPos += width;
                        }
                        break;
                    case INSERT:
                        if (i <= diffs.size() - 2 && diffs.get(i + 1).operation == DiffHelper.Operation.EQUAL) {
                            cursorPos = moveVisualCursorTo(currentPos);
                            if (insertChars(width)) {
                                rawPrint(diff.text);
                                cursorPos += width;
                                currentPos = cursorPos;
                                break;
                            }
                        } else if (i <= diffs.size() - 2
                                && diffs.get(i + 1).operation == DiffHelper.Operation.DELETE
                                && width == diffs.get(i + 1).text.columnLength()) {
                            moveVisualCursorTo(currentPos);
                            rawPrint(diff.text);
                            cursorPos += width;
                            currentPos = cursorPos;
                            i++; // skip delete
                            break;
                        }
                        moveVisualCursorTo(currentPos);
                        rawPrint(diff.text);
                        cursorPos += width;
                        currentPos = cursorPos;
                        ident = false;
                        break;
                    case DELETE:
                        if (cleared) {
                            continue;
                        }
                        if (currentPos - curCol >= columns) {
                            continue;
                        }
                        if (i <= diffs.size() - 2 && diffs.get(i + 1).operation == DiffHelper.Operation.EQUAL) {
                            if (currentPos + diffs.get(i + 1).text.columnLength() < columns) {
                                moveVisualCursorTo(currentPos);
                                if (deleteChars(width)) {
                                    break;
                                }
                            }
                        }
                        int oldLen = oldLine.columnLength();
                        int newLen = newLine.columnLength();
                        int nb = Math.max(oldLen, newLen) - (currentPos - curCol);
                        moveVisualCursorTo(currentPos);
                        if (!terminal.puts(Capability.clr_eol)) {
                            rawPrint(' ', nb);
                            cursorPos += nb;
                        }
                        cleared = true;
                        ident = false;
                        break;
                }
            }
            lineIndex++;
            boolean newWrap = !newNL && lineIndex < newLines.size();
            if (targetCursorPos + 1 == lineIndex * columns1 && (newWrap || !delayLineWrap)) targetCursorPos++;
            boolean atRight = (cursorPos - curCol) % columns1 == columns;
            wrapNeeded = false;
            if (this.delayedWrapAtEol) {
                boolean oldWrap = !oldNL && lineIndex < oldLines.size();
                if (newWrap != oldWrap && !(oldWrap && cleared)) {
                    moveVisualCursorTo(lineIndex * columns1 - 1, newLines);
                    if (newWrap) wrapNeeded = true;
                    else terminal.puts(Capability.clr_eol);
                }
            } else if (atRight) {
                if (this.wrapAtEol) {
                    if (!fullScreen || (fullScreen && lineIndex < numLines)) {
                        rawPrint(' ');
                        terminal.puts(Capability.key_backspace);
                        cursorPos++;
                    }
                } else {
                    terminal.puts(Capability.carriage_return); // CR / not newline.
                    cursorPos = curCol;
                }
                currentPos = cursorPos;
            }
        }
        if (cursorPos != targetCursorPos) {
            moveVisualCursorTo(targetCursorPos < 0 ? currentPos : targetCursorPos, newLines);
        }
        oldLines = newLines;

        if (flush) {
            terminal.flush();
        }
    }

    protected boolean deleteLines(int nb) {
        return perform(Capability.delete_line, Capability.parm_delete_line, nb);
    }

    protected boolean insertLines(int nb) {
        return perform(Capability.insert_line, Capability.parm_insert_line, nb);
    }

    protected boolean insertChars(int nb) {
        return perform(Capability.insert_character, Capability.parm_ich, nb);
    }

    protected boolean deleteChars(int nb) {
        return perform(Capability.delete_character, Capability.parm_dch, nb);
    }

    protected boolean can(Capability single, Capability multi) {
        return terminal.getStringCapability(single) != null || terminal.getStringCapability(multi) != null;
    }

    protected boolean perform(Capability single, Capability multi, int nb) {
        boolean hasMulti = terminal.getStringCapability(multi) != null;
        boolean hasSingle = terminal.getStringCapability(single) != null;
        if (hasMulti && (!hasSingle || cost(single) * nb > cost(multi))) {
            terminal.puts(multi, nb);
            return true;
        } else if (hasSingle) {
            for (int i = 0; i < nb; i++) {
                terminal.puts(single);
            }
            return true;
        } else {
            return false;
        }
    }

    private int cost(Capability cap) {
        return cost.computeIfAbsent(cap, this::computeCost);
    }

    private int computeCost(Capability cap) {
        String s = Curses.tputs(terminal.getStringCapability(cap), 0);
        return s != null ? s.length() : Integer.MAX_VALUE;
    }

    /*
     * Move cursor from cursorPos to argument, updating cursorPos
     * We're at the right margin if {@code (cursorPos % columns1) == columns}.
     * This method knows how to move both *from* and *to* the right margin.
     */
    protected void moveVisualCursorTo(int targetPos, List<AttributedString> newLines) {
        if (cursorPos != targetPos) {
            boolean atRight = (targetPos % columns1) == columns;
            moveVisualCursorTo(targetPos - (atRight ? 1 : 0));
            if (atRight) {
                // There is no portable way to move to the right margin
                // except by writing a character in the right-most column.
                int row = targetPos / columns1;
                AttributedString lastChar = row >= newLines.size()
                        ? AttributedString.EMPTY
                        : newLines.get(row).columnSubSequence(columns - 1, columns);
                if (lastChar.length() == 0) rawPrint(' ');
                else rawPrint(lastChar);
                cursorPos++;
            }
        }
    }

    /*
     * Move cursor from cursorPos to argument, updating cursorPos
     * We're at the right margin if {@code (cursorPos % columns1) == columns}.
     * This method knows how to move *from* the right margin,
     * but does not know how to move *to* the right margin.
     * I.e. {@code (i1 % columns1) == column} is not allowed.
     */
    protected int moveVisualCursorTo(int i1) {
        int i0 = cursorPos;
        if (i0 == i1) return i1;
        int width = columns1;
        int l0 = i0 / width;
        int c0 = i0 % width;
        int l1 = i1 / width;
        int c1 = i1 % width;
        if (c0 == columns) { // at right margin
            terminal.puts(Capability.carriage_return);
            c0 = 0;
        }
        if (l0 > l1) {
            perform(Capability.cursor_up, Capability.parm_up_cursor, l0 - l1);
        } else if (l0 < l1) {
            // TODO: clean the following
            if (fullScreen) {
                if (!terminal.puts(Capability.parm_down_cursor, l1 - l0)) {
                    for (int i = l0; i < l1; i++) {
                        terminal.puts(Capability.cursor_down);
                    }
                    if (cursorDownIsNewLine) {
                        c0 = 0;
                    }
                }
            } else {
                terminal.puts(Capability.carriage_return);
                rawPrint('\n', l1 - l0);
                c0 = 0;
            }
        }
        if (c0 != 0 && c1 == 0) {
            terminal.puts(Capability.carriage_return);
        } else if (c0 < c1) {
            perform(Capability.cursor_right, Capability.parm_right_cursor, c1 - c0);
        } else if (c0 > c1) {
            perform(Capability.cursor_left, Capability.parm_left_cursor, c0 - c1);
        }
        cursorPos = i1;
        return i1;
    }

    void rawPrint(char c, int num) {
        for (int i = 0; i < num; i++) {
            rawPrint(c);
        }
    }

    void rawPrint(int c) {
        terminal.writer().write(c);
    }

    void rawPrint(AttributedString str) {
        str.print(terminal);
    }

    public int wcwidth(String str) {
        return str != null ? AttributedString.fromAnsi(str).columnLength() : 0;
    }
}
