package terminal;

import static com.googlecode.lanterna.input.KeyType.ArrowDown;
import static com.googlecode.lanterna.input.KeyType.ArrowLeft;
import static com.googlecode.lanterna.input.KeyType.ArrowRight;
import static com.googlecode.lanterna.input.KeyType.ArrowUp;
import static com.googlecode.lanterna.input.KeyType.Backspace;
import static com.googlecode.lanterna.input.KeyType.Character;
import static com.googlecode.lanterna.input.KeyType.Delete;
import static com.googlecode.lanterna.input.KeyType.Enter;
import static terminal.ControlSequenceHandler.isEscape;
import static util.Characters.EOF;
import static util.Characters.UNICODE_NULL;

import java.io.ByteArrayInputStream;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import com.googlecode.lanterna.TerminalPosition;
import com.googlecode.lanterna.TerminalSize;
import com.googlecode.lanterna.input.KeyStroke;
import com.googlecode.lanterna.input.KeyType;
import com.googlecode.lanterna.terminal.IOSafeTerminal;

import stream.SafeInputStream;
import stream.SafeOutputStream;

public class LispTerminal {

    public static final char END_OF_SEGMENT = UNICODE_NULL;

    private IOSafeTerminal terminal;
    private SafeOutputStream inputWriter;
    private SafeInputStream outputReader;
    private ControlSequenceHandler controlSequenceHandler;
    private TerminalHistory history;
    private ExecutorService executorService;
    private TerminalSize terminalSize;
    private String inputLine;
    private String outputSegment;
    private boolean isStopped;
    private int originColumn;
    private int originRow;

    public LispTerminal(TerminalConfiguration configuration) {
        this.terminal = configuration.getTerminal();
        this.inputWriter = new SafeOutputStream(configuration.getInputWriter());
        this.outputReader = new SafeInputStream(configuration.getOutputReader());
        this.controlSequenceHandler = new ControlSequenceHandler();
        this.history = new TerminalHistory();
        this.executorService = Executors.newFixedThreadPool(2);
        this.terminalSize = terminal.getTerminalSize();
        this.inputLine = "";
        this.outputSegment = "";
        this.isStopped = false;

        setOriginToCurrentPosition();
        terminal.addResizeListener((t, newSize) -> resize());
    }

    private synchronized void setOriginToCurrentPosition() {
        TerminalPosition cursorPosition = terminal.getCursorPosition();
        originColumn = cursorPosition.getColumn();
        originRow = cursorPosition.getRow();
    }

    private synchronized void resize() {
        terminalSize = terminal.getTerminalSize();
        terminal.clearScreen();
        terminal.setCursorPosition(0, 0);
        redisplayInput();
    }

    private synchronized void redisplayInput() {
        setOriginToCurrentPosition();
        putStringToTerminal(inputLine);
        moveCursorToEndOfInput();
    }

    private synchronized void putStringToTerminal(String characters) {
        for (char c : characters.toCharArray())
            terminal.putCharacter(c);
    }

    private synchronized void moveCursorToEndOfInput() {
        terminal.setCursorPosition(getLeadingEdge());
    }

    private synchronized TerminalPosition getLeadingEdge() {
        int inputLength = inputLine.length();
        int totalColumns = terminalSize.getColumns();
        int rowDifference = inputLength / totalColumns;
        int columnDifference = inputLength % totalColumns;

        return new TerminalPosition(originColumn + columnDifference, originRow + rowDifference);
    }

    public void start() {
        executorService.execute(this::readInput);
        executorService.execute(this::writeOutput);
        executorService.shutdown();
    }

    private void readInput() {
        while (!isStopped)
            processNextKey();
    }

    private void processNextKey() {
        KeyStroke keyStroke = getKeyStroke();

        if (keyStroke != null)
            handleKey(keyStroke);
        else
            takeNap();
    }

    private KeyStroke getKeyStroke() {
        KeyStroke keyStroke = null;

        // issue #299
        try {
            keyStroke = terminal.pollInput();
        } catch (IllegalStateException e) {
            doControlC();
        }

        return keyStroke;
    }

    private synchronized void handleKey(KeyStroke keyStroke) {
        doKey(keyStroke);
        terminal.flush();
    }

    private synchronized void doKey(KeyStroke keyStroke) {
        if (keyStroke.isCtrlDown())
            doControlKey(keyStroke);
        else
            doNormalKey(keyStroke);
    }

    private synchronized void doControlKey(KeyStroke keyStroke) {
        KeyType keyType = keyStroke.getKeyType();

        if (keyType == Character)
            doControlCharacter(keyStroke);
    }

    private synchronized void doControlCharacter(KeyStroke keyStroke) {
        if (keyStroke.getCharacter() == 'c')
            doControlC();
        else if (keyStroke.getCharacter() == 'd')
            doControlD();
    }

    private synchronized void doControlC() {
        moveCursorToEndOfInput();
        terminal.putCharacter('\n');
        inputLine = "";
        setOriginToCurrentPosition();
        stop();
    }

    private synchronized void doControlD() {
        doEnter();
        stop();
    }

    private synchronized void doNormalKey(KeyStroke keyStroke) {
        KeyType keyType = keyStroke.getKeyType();

        if (keyType == Enter)
            doEnter();
        else if (keyType == ArrowUp)
            doUpArrow();
        else if (keyType == ArrowDown)
            doDownArrow();
        else if (keyType == ArrowLeft)
            doLeftArrow();
        else if (keyType == ArrowRight)
            doRightArrow();
        else if (keyType == Backspace)
            doBackspace();
        else if (keyType == Delete)
            doDelete();
        else if (keyType == Character)
            doCharacter(keyStroke.getCharacter());
    }

    private synchronized void doEnter() {
        moveCursorToEndOfInput();
        history.addLine(inputLine);
        terminal.putCharacter('\n');
        writeInputLine();
        setOriginToCurrentPosition();
    }

    private synchronized void writeInputLine() {
        inputLine += "\n";
        inputWriter.write(inputLine.getBytes());
        inputWriter.flush();
        inputLine = "";
    }

    private synchronized void doUpArrow() {
        if (!history.isBeginning())
            replaceInputWithPreviousLine();
    }

    private synchronized void replaceInputWithPreviousLine() {
        history.updateCurrentLine(inputLine);
        clearInput();
        displayPreviousInputLine();
    }

    private synchronized void clearInput() {
        terminal.setCursorPosition(originColumn, originRow);
        putStringToTerminal(inputLine.replaceAll(".{1}", " "));
        terminal.setCursorPosition(originColumn, originRow);
    }

    private synchronized void displayPreviousInputLine() {
        inputLine = history.getPreviousLine();
        char[] inputCharacters = inputLine.toCharArray();

        for (int i = 0; i < inputCharacters.length; i++)
            displayCharacter(inputCharacters[i], i);
    }

    private synchronized void displayCharacter(char character, int offsetFromOrigin) {
        if (isLastColumn(offsetFromOrigin)) {
            TerminalPosition cursorPosition = terminal.getCursorPosition();
            terminal.putCharacter(character);
            moveCursorToNextRow(cursorPosition);
        } else
            terminal.putCharacter(character);
    }

    private synchronized boolean isLastColumn(int offsetFromOrigin) {
        int totalColumns = terminalSize.getColumns();

        return (originColumn + offsetFromOrigin) % totalColumns == totalColumns - 1;
    }

    private synchronized void doDownArrow() {
        if (!history.isEnd())
            replaceInputWithNextLine();
    }

    private synchronized void replaceInputWithNextLine() {
        history.updateCurrentLine(inputLine);
        clearInput();
        displayNextInputLine();
    }

    private synchronized void displayNextInputLine() {
        inputLine = history.getNextLine();
        putStringToTerminal(inputLine);
    }

    private synchronized void doLeftArrow() {
        TerminalPosition cursorPosition = terminal.getCursorPosition();

        if (isPossibleToMoveLeft(cursorPosition))
            moveCursorLeft(cursorPosition);
    }

    private synchronized boolean isPossibleToMoveLeft(TerminalPosition cursorPosition) {
        return getDistanceFromOrigin(cursorPosition) > 0;
    }

    private synchronized int getDistanceFromOrigin(TerminalPosition cursorPosition) {
        int columnDifference = cursorPosition.getColumn() - originColumn;
        int rowDifference = cursorPosition.getRow() - originRow;
        int totalColumns = terminalSize.getColumns();

        return columnDifference + (totalColumns * rowDifference);
    }

    private synchronized void moveCursorLeft(TerminalPosition cursorPosition) {
        TerminalPosition newPosition = cursorPosition.withRelativeColumn(-1);

        if (isAtStartOfRow(cursorPosition))
            newPosition = cursorPosition.withColumn(terminalSize.getColumns()).withRelativeRow(-1);

        terminal.setCursorPosition(newPosition);
    }

    private synchronized boolean isAtStartOfRow(TerminalPosition cursorPosition) {
        return cursorPosition.getColumn() == 0;
    }

    private synchronized void doRightArrow() {
        TerminalPosition cursorPosition = terminal.getCursorPosition();

        if (isPossibleToMoveRight(cursorPosition))
            moveCursorRight(cursorPosition);
    }

    private synchronized boolean isPossibleToMoveRight(TerminalPosition cursorPosition) {
        return getDistanceFromOrigin(cursorPosition) < inputLine.length();
    }

    private synchronized void moveCursorRight(TerminalPosition cursorPosition) {
        if (isEndOfRow(cursorPosition))
            moveCursorToNextRow(cursorPosition);
        else
            terminal.setCursorPosition(cursorPosition.withRelativeColumn(1));
    }

    private synchronized boolean isEndOfRow(TerminalPosition cursorPosition) {
        return cursorPosition.getColumn() >= terminalSize.getColumns() - 1;
    }

    private synchronized void moveCursorToNextRow(TerminalPosition cursorPosition) {
        if (isEndOfBuffer(cursorPosition))
            createNewRowForCursor(cursorPosition);
        else
            terminal.setCursorPosition(cursorPosition.withColumn(0).withRelativeRow(1));
    }

    private synchronized boolean isEndOfBuffer(TerminalPosition cursorPosition) {
        return cursorPosition.getRow() == terminalSize.getRows() - 1;
    }

    private synchronized void createNewRowForCursor(TerminalPosition cursorPosition) {
        terminal.setCursorPosition(cursorPosition);
        terminal.putCharacter('\n');
        terminal.setCursorPosition(cursorPosition.withColumn(0));
        --originRow;
    }

    private synchronized void doBackspace() {
        TerminalPosition cursorPosition = terminal.getCursorPosition();

        if (isPossibleToMoveLeft(cursorPosition))
            deletePreviousCharacter(cursorPosition);
    }

    private synchronized void deletePreviousCharacter(TerminalPosition cursorPosition) {
        int distanceFromOrigin = getDistanceFromOrigin(cursorPosition);
        String remaining = inputLine.substring(distanceFromOrigin, inputLine.length());
        inputLine = inputLine.substring(0, distanceFromOrigin - 1) + remaining;
        moveCursorLeft(cursorPosition);
        putStringToTerminal(remaining + " ");
        moveCursorLeft(cursorPosition);
    }

    private synchronized void doDelete() {
        TerminalPosition cursorPosition = terminal.getCursorPosition();

        if (isPossibleToMoveRight(cursorPosition))
            deleteCharacterAtPosition(cursorPosition);
    }

    private synchronized void deleteCharacterAtPosition(TerminalPosition cursorPosition) {
        int distanceFromOrigin = getDistanceFromOrigin(cursorPosition);
        String remaining = inputLine.substring(distanceFromOrigin + 1, inputLine.length());
        inputLine = inputLine.substring(0, distanceFromOrigin) + remaining;
        putStringToTerminal(remaining + " ");
        terminal.setCursorPosition(cursorPosition);
    }

    private synchronized void doCharacter(Character character) {
        TerminalPosition cursorPosition = terminal.getCursorPosition();

        if (!isBufferFilled())
            if (isPossibleToMoveRight(cursorPosition))
                insertCharacter(character, cursorPosition);
            else
                appendCharacter(character, cursorPosition);
    }

    private synchronized boolean isBufferFilled() {
        int row = getLeadingEdge().getRow();
        int column = getLeadingEdge().getColumn();

        return (row == terminalSize.getRows() - 1) && (column >= terminalSize.getColumns() - 1) && (originRow <= 0);
    }

    private synchronized void insertCharacter(Character character, TerminalPosition cursorPosition) {
        cursorPosition = shiftPositionIfNewRowAdded(cursorPosition);
        terminal.setCursorPosition(cursorPosition);
        int distanceFromOrigin = getDistanceFromOrigin(cursorPosition);
        String remaining = character + inputLine.substring(distanceFromOrigin, inputLine.length());
        inputLine = inputLine.substring(0, distanceFromOrigin) + remaining;
        putStringToTerminal(remaining);
        moveCursorRight(cursorPosition);
    }

    private synchronized TerminalPosition shiftPositionIfNewRowAdded(TerminalPosition cursorPosition) {
        int oldOriginRow = originRow;
        moveCursorRight(getLeadingEdge());

        return isNewRowAdded(oldOriginRow) ? adjustCursorPosition(cursorPosition) : cursorPosition;
    }

    private synchronized boolean isNewRowAdded(int oldOriginRow) {
        return originRow != oldOriginRow;
    }

    private synchronized TerminalPosition adjustCursorPosition(TerminalPosition cursorPosition) {
        terminal.setCursorPosition(new TerminalPosition(originColumn, originRow));
        putStringToTerminal(inputLine);

        return cursorPosition.withRelativeRow(-1);
    }

    private synchronized void appendCharacter(Character character, TerminalPosition cursorPosition) {
        terminal.putCharacter(character);
        inputLine += character;
        moveCursorRight(cursorPosition);
    }

    private void takeNap() {
        try {
            Thread.sleep(1);
        } catch (InterruptedException ignored) {}
    }

    private void writeOutput() {
        for (int c = outputReader.read(); c != EOF; c = outputReader.read())
            processOutput((char) c);

        terminal.flush();
        terminal.close();
    }

    private synchronized void processOutput(char c) {
        if (isEndOfSegment(c))
            writeSegment();
        else
            outputSegment += c;
    }

    private synchronized boolean isEndOfSegment(char c) {
        return c == END_OF_SEGMENT;
    }

    private synchronized void writeSegment() {
        printSegmentCharacters();
        outputSegment = "";
        redisplayInput();
        terminal.flush();
    }

    private synchronized void printSegmentCharacters() {
        moveCursorToEndOfInput();
        putOutputToTerminal();
        moveCursorToNextRowIfNecessary();
    }

    private synchronized void putOutputToTerminal() {
        SafeInputStream input = convertOutputToStream();

        for (int c = input.read(); c != EOF; c = input.read())
            if (isEscape((char) c))
                applyControlSequence(input);
            else
                terminal.putCharacter((char) c);
    }

    private synchronized SafeInputStream convertOutputToStream() {
        return new SafeInputStream(new ByteArrayInputStream(outputSegment.getBytes()));
    }

    private synchronized void applyControlSequence(SafeInputStream input) {
        ControlSequence controlSequence = controlSequenceHandler.parse(input);
        controlSequence.applyTo(terminal);
    }

    private synchronized void moveCursorToNextRowIfNecessary() {
        TerminalPosition cursorPosition = terminal.getCursorPosition();

        if (isEndOfRow(cursorPosition))
            moveCursorToNextRow(cursorPosition);
    }

    public void stop() {
        isStopped = true;
        inputWriter.close();
    }

}
