# -*- test-case-name: twisted.conch.test.test_helper -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.

"""
Partial in-memory terminal emulator

@author: Jp Calderone
"""


import re
import string

from zope.interface import implementer

from incremental import Version

from twisted.conch.insults import insults
from twisted.internet import defer, protocol, reactor
from twisted.logger import Logger
from twisted.python import _textattributes
from twisted.python.compat import iterbytes
from twisted.python.deprecate import deprecated, deprecatedModuleAttribute

FOREGROUND = 30
BACKGROUND = 40
BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, N_COLORS = range(9)


class _FormattingState(_textattributes._FormattingStateMixin):
    """
    Represents the formatting state/attributes of a single character.

    Character set, intensity, underlinedness, blinkitude, video
    reversal, as well as foreground and background colors made up a
    character's attributes.
    """

    compareAttributes = (
        "charset",
        "bold",
        "underline",
        "blink",
        "reverseVideo",
        "foreground",
        "background",
        "_subtracting",
    )

    def __init__(
        self,
        charset=insults.G0,
        bold=False,
        underline=False,
        blink=False,
        reverseVideo=False,
        foreground=WHITE,
        background=BLACK,
        _subtracting=False,
    ):
        self.charset = charset
        self.bold = bold
        self.underline = underline
        self.blink = blink
        self.reverseVideo = reverseVideo
        self.foreground = foreground
        self.background = background
        self._subtracting = _subtracting

    @deprecated(Version("Twisted", 13, 1, 0))
    def wantOne(self, **kw):
        """
        Add a character attribute to a copy of this formatting state.

        @param kw: An optional attribute name and value can be provided with
            a keyword argument.

        @return: A formatting state instance with the new attribute.

        @see: L{DefaultFormattingState._withAttribute}.
        """
        k, v = kw.popitem()
        return self._withAttribute(k, v)

    def toVT102(self):
        # Spit out a vt102 control sequence that will set up
        # all the attributes set here.  Except charset.
        attrs = []
        if self._subtracting:
            attrs.append(0)
        if self.bold:
            attrs.append(insults.BOLD)
        if self.underline:
            attrs.append(insults.UNDERLINE)
        if self.blink:
            attrs.append(insults.BLINK)
        if self.reverseVideo:
            attrs.append(insults.REVERSE_VIDEO)
        if self.foreground != WHITE:
            attrs.append(FOREGROUND + self.foreground)
        if self.background != BLACK:
            attrs.append(BACKGROUND + self.background)
        if attrs:
            return "\x1b[" + ";".join(map(str, attrs)) + "m"
        return ""


CharacterAttribute = _FormattingState

deprecatedModuleAttribute(
    Version("Twisted", 13, 1, 0),
    "Use twisted.conch.insults.text.assembleFormattedText instead.",
    "twisted.conch.insults.helper",
    "CharacterAttribute",
)


# XXX - need to support scroll regions and scroll history
@implementer(insults.ITerminalTransport)
class TerminalBuffer(protocol.Protocol):
    """
    An in-memory terminal emulator.
    """

    for keyID in (
        b"UP_ARROW",
        b"DOWN_ARROW",
        b"RIGHT_ARROW",
        b"LEFT_ARROW",
        b"HOME",
        b"INSERT",
        b"DELETE",
        b"END",
        b"PGUP",
        b"PGDN",
        b"F1",
        b"F2",
        b"F3",
        b"F4",
        b"F5",
        b"F6",
        b"F7",
        b"F8",
        b"F9",
        b"F10",
        b"F11",
        b"F12",
    ):
        execBytes = keyID + b" = object()"
        execStr = execBytes.decode("ascii")
        exec(execStr)

    TAB = b"\t"
    BACKSPACE = b"\x7f"

    width = 80
    height = 24

    fill = b" "
    void = object()
    _log = Logger()

    def getCharacter(self, x, y):
        return self.lines[y][x]

    def connectionMade(self):
        self.reset()

    def write(self, data):
        """
        Add the given printable bytes to the terminal.

        Line feeds in L{bytes} will be replaced with carriage return / line
        feed pairs.
        """
        for b in iterbytes(data.replace(b"\n", b"\r\n")):
            self.insertAtCursor(b)

    def _currentFormattingState(self):
        return _FormattingState(self.activeCharset, **self.graphicRendition)

    def insertAtCursor(self, b):
        """
        Add one byte to the terminal at the cursor and make consequent state
        updates.

        If b is a carriage return, move the cursor to the beginning of the
        current row.

        If b is a line feed, move the cursor to the next row or scroll down if
        the cursor is already in the last row.

        Otherwise, if b is printable, put it at the cursor position (inserting
        or overwriting as dictated by the current mode) and move the cursor.
        """
        if b == b"\r":
            self.x = 0
        elif b == b"\n":
            self._scrollDown()
        elif b in string.printable.encode("ascii"):
            if self.x >= self.width:
                self.nextLine()
            ch = (b, self._currentFormattingState())
            if self.modes.get(insults.modes.IRM):
                self.lines[self.y][self.x : self.x] = [ch]
                self.lines[self.y].pop()
            else:
                self.lines[self.y][self.x] = ch
            self.x += 1

    def _emptyLine(self, width):
        return [(self.void, self._currentFormattingState()) for i in range(width)]

    def _scrollDown(self):
        self.y += 1
        if self.y >= self.height:
            self.y -= 1
            del self.lines[0]
            self.lines.append(self._emptyLine(self.width))

    def _scrollUp(self):
        self.y -= 1
        if self.y < 0:
            self.y = 0
            del self.lines[-1]
            self.lines.insert(0, self._emptyLine(self.width))

    def cursorUp(self, n=1):
        self.y = max(0, self.y - n)

    def cursorDown(self, n=1):
        self.y = min(self.height - 1, self.y + n)

    def cursorBackward(self, n=1):
        self.x = max(0, self.x - n)

    def cursorForward(self, n=1):
        self.x = min(self.width, self.x + n)

    def cursorPosition(self, column, line):
        self.x = column
        self.y = line

    def cursorHome(self):
        self.x = self.home.x
        self.y = self.home.y

    def index(self):
        self._scrollDown()

    def reverseIndex(self):
        self._scrollUp()

    def nextLine(self):
        """
        Update the cursor position attributes and scroll down if appropriate.
        """
        self.x = 0
        self._scrollDown()

    def saveCursor(self):
        self._savedCursor = (self.x, self.y)

    def restoreCursor(self):
        self.x, self.y = self._savedCursor
        del self._savedCursor

    def setModes(self, modes):
        for m in modes:
            self.modes[m] = True

    def resetModes(self, modes):
        for m in modes:
            try:
                del self.modes[m]
            except KeyError:
                pass

    def setPrivateModes(self, modes):
        """
        Enable the given modes.

        Track which modes have been enabled so that the implementations of
        other L{insults.ITerminalTransport} methods can be properly implemented
        to respect these settings.

        @see: L{resetPrivateModes}
        @see: L{insults.ITerminalTransport.setPrivateModes}
        """
        for m in modes:
            self.privateModes[m] = True

    def resetPrivateModes(self, modes):
        """
        Disable the given modes.

        @see: L{setPrivateModes}
        @see: L{insults.ITerminalTransport.resetPrivateModes}
        """
        for m in modes:
            try:
                del self.privateModes[m]
            except KeyError:
                pass

    def applicationKeypadMode(self):
        self.keypadMode = "app"

    def numericKeypadMode(self):
        self.keypadMode = "num"

    def selectCharacterSet(self, charSet, which):
        self.charsets[which] = charSet

    def shiftIn(self):
        self.activeCharset = insults.G0

    def shiftOut(self):
        self.activeCharset = insults.G1

    def singleShift2(self):
        oldActiveCharset = self.activeCharset
        self.activeCharset = insults.G2
        f = self.insertAtCursor

        def insertAtCursor(b):
            f(b)
            del self.insertAtCursor
            self.activeCharset = oldActiveCharset

        self.insertAtCursor = insertAtCursor

    def singleShift3(self):
        oldActiveCharset = self.activeCharset
        self.activeCharset = insults.G3
        f = self.insertAtCursor

        def insertAtCursor(b):
            f(b)
            del self.insertAtCursor
            self.activeCharset = oldActiveCharset

        self.insertAtCursor = insertAtCursor

    def selectGraphicRendition(self, *attributes):
        for a in attributes:
            if a == insults.NORMAL:
                self.graphicRendition = {
                    "bold": False,
                    "underline": False,
                    "blink": False,
                    "reverseVideo": False,
                    "foreground": WHITE,
                    "background": BLACK,
                }
            elif a == insults.BOLD:
                self.graphicRendition["bold"] = True
            elif a == insults.UNDERLINE:
                self.graphicRendition["underline"] = True
            elif a == insults.BLINK:
                self.graphicRendition["blink"] = True
            elif a == insults.REVERSE_VIDEO:
                self.graphicRendition["reverseVideo"] = True
            else:
                try:
                    v = int(a)
                except ValueError:
                    self._log.error(
                        "Unknown graphic rendition attribute: {attr!r}", attr=a
                    )
                else:
                    if FOREGROUND <= v <= FOREGROUND + N_COLORS:
                        self.graphicRendition["foreground"] = v - FOREGROUND
                    elif BACKGROUND <= v <= BACKGROUND + N_COLORS:
                        self.graphicRendition["background"] = v - BACKGROUND
                    else:
                        self._log.error(
                            "Unknown graphic rendition attribute: {attr!r}", attr=a
                        )

    def eraseLine(self):
        self.lines[self.y] = self._emptyLine(self.width)

    def eraseToLineEnd(self):
        width = self.width - self.x
        self.lines[self.y][self.x :] = self._emptyLine(width)

    def eraseToLineBeginning(self):
        self.lines[self.y][: self.x + 1] = self._emptyLine(self.x + 1)

    def eraseDisplay(self):
        self.lines = [self._emptyLine(self.width) for i in range(self.height)]

    def eraseToDisplayEnd(self):
        self.eraseToLineEnd()
        height = self.height - self.y - 1
        self.lines[self.y + 1 :] = [self._emptyLine(self.width) for i in range(height)]

    def eraseToDisplayBeginning(self):
        self.eraseToLineBeginning()
        self.lines[: self.y] = [self._emptyLine(self.width) for i in range(self.y)]

    def deleteCharacter(self, n=1):
        del self.lines[self.y][self.x : self.x + n]
        self.lines[self.y].extend(self._emptyLine(min(self.width - self.x, n)))

    def insertLine(self, n=1):
        self.lines[self.y : self.y] = [self._emptyLine(self.width) for i in range(n)]
        del self.lines[self.height :]

    def deleteLine(self, n=1):
        del self.lines[self.y : self.y + n]
        self.lines.extend([self._emptyLine(self.width) for i in range(n)])

    def reportCursorPosition(self):
        return (self.x, self.y)

    def reset(self):
        self.home = insults.Vector(0, 0)
        self.x = self.y = 0
        self.modes = {}
        self.privateModes = {}
        self.setPrivateModes(
            [insults.privateModes.AUTO_WRAP, insults.privateModes.CURSOR_MODE]
        )
        self.numericKeypad = "app"
        self.activeCharset = insults.G0
        self.graphicRendition = {
            "bold": False,
            "underline": False,
            "blink": False,
            "reverseVideo": False,
            "foreground": WHITE,
            "background": BLACK,
        }
        self.charsets = {
            insults.G0: insults.CS_US,
            insults.G1: insults.CS_US,
            insults.G2: insults.CS_ALTERNATE,
            insults.G3: insults.CS_ALTERNATE_SPECIAL,
        }
        self.eraseDisplay()

    def unhandledControlSequence(self, buf):
        print("Could not handle", repr(buf))

    def __bytes__(self):
        lines = []
        for L in self.lines:
            buf = []
            length = 0
            for ch, attr in L:
                if ch is not self.void:
                    buf.append(ch)
                    length = len(buf)
                else:
                    buf.append(self.fill)
            lines.append(b"".join(buf[:length]))
        return b"\n".join(lines)

    def getHost(self):
        # ITransport.getHost
        raise NotImplementedError("Unimplemented: TerminalBuffer.getHost")

    def getPeer(self):
        # ITransport.getPeer
        raise NotImplementedError("Unimplemented: TerminalBuffer.getPeer")

    def loseConnection(self):
        # ITransport.loseConnection
        raise NotImplementedError("Unimplemented: TerminalBuffer.loseConnection")

    def writeSequence(self, data):
        # ITransport.writeSequence
        raise NotImplementedError("Unimplemented: TerminalBuffer.writeSequence")

    def horizontalTabulationSet(self):
        # ITerminalTransport.horizontalTabulationSet
        raise NotImplementedError(
            "Unimplemented: TerminalBuffer.horizontalTabulationSet"
        )

    def tabulationClear(self):
        # TerminalTransport.tabulationClear
        raise NotImplementedError("Unimplemented: TerminalBuffer.tabulationClear")

    def tabulationClearAll(self):
        # TerminalTransport.tabulationClearAll
        raise NotImplementedError("Unimplemented: TerminalBuffer.tabulationClearAll")

    def doubleHeightLine(self, top=True):
        # ITerminalTransport.doubleHeightLine
        raise NotImplementedError("Unimplemented: TerminalBuffer.doubleHeightLine")

    def singleWidthLine(self):
        # ITerminalTransport.singleWidthLine
        raise NotImplementedError("Unimplemented: TerminalBuffer.singleWidthLine")

    def doubleWidthLine(self):
        # ITerminalTransport.doubleWidthLine
        raise NotImplementedError("Unimplemented: TerminalBuffer.doubleWidthLine")


class ExpectationTimeout(Exception):
    pass


class ExpectableBuffer(TerminalBuffer):
    _mark = 0

    def connectionMade(self):
        TerminalBuffer.connectionMade(self)
        self._expecting = []

    def write(self, data):
        TerminalBuffer.write(self, data)
        self._checkExpected()

    def cursorHome(self):
        TerminalBuffer.cursorHome(self)
        self._mark = 0

    def _timeoutExpected(self, d):
        d.errback(ExpectationTimeout())
        self._checkExpected()

    def _checkExpected(self):
        s = self.__bytes__()[self._mark :]
        while self._expecting:
            expr, timer, deferred = self._expecting[0]
            if timer and not timer.active():
                del self._expecting[0]
                continue
            for match in expr.finditer(s):
                if timer:
                    timer.cancel()
                del self._expecting[0]
                self._mark += match.end()
                s = s[match.end() :]
                deferred.callback(match)
                break
            else:
                return

    def expect(self, expression, timeout=None, scheduler=reactor):
        d = defer.Deferred()
        timer = None
        if timeout:
            timer = scheduler.callLater(timeout, self._timeoutExpected, d)
        self._expecting.append((re.compile(expression), timer, d))
        self._checkExpected()
        return d


__all__ = ["CharacterAttribute", "TerminalBuffer", "ExpectableBuffer"]
