from __future__ import annotations

import getpass
import os
import subprocess

from typing import TYPE_CHECKING
from typing import Any
from typing import Callable

from cleo.formatters.style import Style


if TYPE_CHECKING:
    from cleo.io.io import IO


class Question:
    """
    A question that will be asked in a Console.
    """

    def __init__(self, question: str, default: Any | None = None) -> None:
        self._question = question
        self._default = default

        self._attempts = None
        self._hidden = False
        self._hidden_fallback = True
        self._autocomplete_values = None
        self._validator = None
        self._normalizer = None
        self._error_message = None

    @property
    def question(self) -> str:
        return self._question

    @property
    def default(self) -> str:
        return self._default

    @property
    def autocomplete_values(self):
        return self._autocomplete_values

    @property
    def max_attempts(self) -> int | None:
        return self._attempts

    def is_hidden(self) -> bool:
        return self._hidden

    def hide(self, hidden: bool = True) -> None:
        if hidden is True and self._autocomplete_values:
            raise RuntimeError("A hidden question cannot use the autocompleter.")

        self._hidden = hidden

    def set_autocomplete_values(self, autocomplete_values) -> None:
        if self.is_hidden():
            raise RuntimeError("A hidden question cannot use the autocompleter.")

        self._autocomplete_values = autocomplete_values

    def set_max_attempts(self, attempts: int):
        self._attempts = attempts

    def set_validator(self, validator: Callable) -> None:
        self._validator = validator

    def ask(self, io: IO) -> str | None:
        """
        Asks the question to the user.
        """
        if not io.is_interactive():
            return self.default

        if not self._validator:
            return self._do_ask(io)

        def interviewer():
            return self._do_ask(io)

        return self._validate_attempts(interviewer, io)

    def _do_ask(self, io: IO) -> str | None:
        """
        Asks the question to the user.
        """
        self._write_prompt(io)

        autocomplete = self._autocomplete_values

        if autocomplete is None or not self._has_stty_available():
            ret = False

            if self.is_hidden():
                try:
                    ret = self._get_hidden_response(io)
                except RuntimeError:
                    if not self._hidden_fallback:
                        raise

            if not ret:
                ret = self._read_from_input(io)
        else:
            ret = self._autocomplete(io)

        if len(ret) <= 0:
            ret = self._default

        if self._normalizer:
            return self._normalizer(ret)

        return ret

    def _write_prompt(self, io: IO) -> None:
        """
        Outputs the question prompt.
        """
        message = self._question

        io.write_error(f"<question>{message}</question> ")

    def _write_error(self, io: IO, error: Exception) -> None:
        """
        Outputs an error message.
        """
        message = f"<error>{str(error)}</error>"

        io.write_error_line(message)

    def _autocomplete(self, io: IO) -> str:
        """
        Autocomplete a question.
        """
        autocomplete = self._autocomplete_values

        ret = ""

        i = 0
        ofs = -1
        matches = list(autocomplete)
        num_matches = len(matches)

        stty_mode = subprocess.check_output(["stty", "-g"]).decode().rstrip("\n")

        # Disable icanon (so we can read each keypress) and
        # echo (we'll do echoing here instead)
        subprocess.check_output(["stty", "-icanon", "-echo"])

        # Add highlighted text style
        style = Style(options=["reverse"])
        io.error_output.formatter.set_style("hl", style)

        # Read a keypress
        while True:
            c = io.read(1)

            # Backspace character
            if c == "\177":
                if num_matches == 0 and i != 0:
                    i -= 1
                    # Move cursor backwards
                    io.write_error("\033[1D")

                if i == 0:
                    ofs = -1
                    matches = list(autocomplete)
                    num_matches = len(matches)
                else:
                    num_matches = 0

                # Pop the last character off the end of our string
                ret = ret[:i]
            # Did we read an escape sequence
            elif c == "\033":
                c += io.read(2)

                # A = Up Arrow. B = Down Arrow
                if c[2] == "A" or c[2] == "B":
                    if c[2] == "A" and ofs == -1:
                        ofs = 0

                    if num_matches == 0:
                        continue

                    ofs += -1 if c[2] == "A" else 1
                    ofs = (num_matches + ofs) % num_matches
            elif ord(c) < 32:
                if c in ["\t", "\n"]:
                    if num_matches > 0 and ofs != -1:
                        ret = matches[ofs]
                        # Echo out remaining chars for current match
                        io.write_error(ret[i:])
                        i = len(ret)

                    if c == "\n":
                        io.write_error(c)
                        break

                    num_matches = 0

                continue
            else:
                io.write_error(c)
                ret += c
                i += 1

                num_matches = 0
                ofs = 0

                for value in autocomplete:
                    # If typed characters match the beginning
                    # chunk of value (e.g. [AcmeDe]moBundle)
                    if value.startswith(ret) and i != len(value):
                        num_matches += 1
                        matches[num_matches - 1] = value

            # Erase characters from cursor to end of line
            io.write_error("\033[K")

            if num_matches > 0 and ofs != -1:
                # Save cursor position
                io.write_error("\0337")
                # Write highlighted text
                io.write_error("<hl>" + matches[ofs][i:] + "</hl>")
                # Restore cursor position
                io.write_error("\0338")

        subprocess.call(["stty", f"{stty_mode}"])

        return ret

    def _get_hidden_response(self, io: IO) -> str:
        """
        Gets a hidden response from user.
        """
        return getpass.getpass("", stream=io.error_output.stream)

    def _validate_attempts(self, interviewer: Callable, io: IO) -> str:
        """
        Validates an attempt.
        """
        error = None
        attempts = self._attempts

        while attempts is None or attempts:
            if error is not None:
                self._write_error(io, error)

            try:
                return self._validator(interviewer())
            except Exception as e:
                error = e

            if attempts is not None:
                attempts -= 1

        raise error

    def _read_from_input(self, io: IO) -> str:
        """
        Read user input.
        """
        ret = io.read_line(4096)

        if not ret:
            raise RuntimeError("Aborted")

        return ret.strip()

    def _has_stty_available(self) -> bool:
        with open(os.devnull, "w") as devnull:
            try:
                exit_code = subprocess.call(["stty"], stdout=devnull, stderr=devnull)
            except Exception:
                exit_code = 2

        return exit_code == 0
