# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Interactive terminal prompt that forces a bootstrap password change before
Unsloth is exposed on a public Cloudflare URL (``--secure`` / ``--cloudflare``).

Masked input echoes one ``*`` per keystroke (unlike ``getpass``). Works on
Windows (``msvcrt``) and Linux/macOS (``termios``). All output goes to stderr so
redirected stdout never swallows the prompt.

Mirrored for the CLI at ``unsloth_cli/commands/_password_prompt.py`` (the CLI
cannot import the Unsloth backend package); keep the two in sync.
"""

from __future__ import annotations

import os
import sys
from typing import Callable, TextIO

_CTRL_C = "\x03"
_CTRL_D = "\x04"
_CTRL_Z = "\x1a"
_BACKSPACES = ("\x7f", "\x08")
_SUBMITS = ("\r", "\n")

# Env var that supplies the initial admin password non-interactively (mirror in
# unsloth_cli/commands/_password_prompt.py). Keep the name in sync.
SUPPLIED_PASSWORD_ENV = "UNSLOTH_STUDIO_PASSWORD"


def _getch_windows() -> str:  # pragma: no cover - exercised via fake on Linux CI
    import msvcrt

    ch = msvcrt.getwch()
    # Function/arrow keys arrive as a two-wchar \x00/\xe0 sequence; consume the
    # second half and report a no-op control char.
    if ch in ("\x00", "\xe0"):
        msvcrt.getwch()
        return "\x00"
    return ch


class _RestoreTtyOnSignals:
    """Restore terminal attrs if SIGTERM/SIGHUP kills the prompt mid-read.

    A finally block can't run when a signal terminates the process, leaving the
    shared terminal in cbreak/no-echo. Best-effort: no-op off the main thread or
    where the signals are absent.
    """

    def __init__(self, fd: int, old_attrs) -> None:
        self._fd = fd
        self._old_attrs = old_attrs
        self._previous: list = []

    def __enter__(self) -> "_RestoreTtyOnSignals":
        import signal
        import termios

        def _restore_and_reraise(signum, frame):
            termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
            signal.signal(signum, signal.SIG_DFL)
            signal.raise_signal(signum)

        for name in ("SIGTERM", "SIGHUP"):
            sig = getattr(signal, name, None)
            if sig is None:
                continue
            try:
                self._previous.append((sig, signal.signal(sig, _restore_and_reraise)))
            except (ValueError, OSError):  # non-main thread / unsupported
                pass
        return self

    def __exit__(self, *exc) -> None:
        import signal
        for sig, previous in self._previous:
            try:
                signal.signal(sig, previous)
            except (ValueError, OSError):
                pass


class _prompt_raw_mode:
    """Hold cbreak + cleared ISIG (no echo) on stdin for the WHOLE prompt line,
    restoring when the line finishes (and on SIGTERM/SIGHUP).

    Echo must never re-enable mid-line: cbreak echoes on receipt, so a keystroke
    arriving while echo is on would appear in cleartext. One cbreak block for the
    whole line closes that window. No-op when stdin is not a real terminal, so
    the _getch seam can be faked in tests.
    """

    def __enter__(self) -> "_prompt_raw_mode":
        self._fd = None
        self._old_attrs = None
        self._signals = None
        try:
            import termios
            import tty
        except ImportError:  # non-POSIX (Windows uses msvcrt, no mode to hold)
            return self
        try:
            fd = sys.stdin.fileno()
            old_attrs = termios.tcgetattr(fd)
        except (AttributeError, ValueError, OSError, termios.error):
            return self  # redirected / captured stdin (tests): nothing to hold
        self._fd = fd
        self._old_attrs = old_attrs
        self._signals = _RestoreTtyOnSignals(fd, old_attrs)
        self._signals.__enter__()
        # cbreak (not raw) keeps output post-processing while disabling echo/line
        # buffering. It leaves ISIG on, so clear it and surface Ctrl-C as \x03 to
        # the caller loop, which restores the tty itself.
        tty.setcbreak(fd, termios.TCSADRAIN)
        new_attrs = termios.tcgetattr(fd)
        new_attrs[3] &= ~termios.ISIG
        termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
        return self

    def __exit__(self, *exc) -> None:
        if self._old_attrs is None:
            return
        import termios
        try:
            termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
        finally:
            if self._signals is not None:
                self._signals.__exit__(*exc)


def _getch_posix() -> str:  # pragma: no cover - needs a real tty
    # Terminal already in cbreak+no-echo for the whole line (_prompt_raw_mode),
    # so just read. Byte-at-a-time incremental decode so a multi-byte UTF-8 char
    # straddling a read boundary isn't dropped.
    import codecs

    fd = sys.stdin.fileno()
    decoder = codecs.getincrementaldecoder(sys.stdin.encoding or "utf-8")("replace")
    while True:
        b = os.read(fd, 1)
        if not b:
            return ""  # stream EOF; caller raises EOFError
        ch = decoder.decode(b)
        if ch:
            return ch


_getch: Callable[[], str] = _getch_windows if os.name == "nt" else _getch_posix


def _read_password(prompt: str, *, out: "TextIO | None" = None) -> str:
    """Read one masked line: echo ``*`` per char, support backspace editing.

    Raises KeyboardInterrupt on Ctrl-C and EOFError on Ctrl-D/Ctrl-Z with an
    empty buffer; the terminal is restored on every exit path.
    """
    if out is None:
        out = sys.stderr
    out.write(prompt)
    out.flush()
    chars: list[str] = []
    with _prompt_raw_mode():
        while True:
            key = _getch()
            if key == "":  # stream ended mid-line: abort, don't submit a partial
                out.write("\n")
                out.flush()
                raise EOFError
            for ch in key:  # a paste can deliver several chars per read
                if ch in _SUBMITS:
                    out.write("\n")
                    out.flush()
                    return "".join(chars)
                if ch == _CTRL_C:
                    out.write("\n")
                    out.flush()
                    raise KeyboardInterrupt
                if ch in (_CTRL_D, _CTRL_Z):
                    if not chars:
                        out.write("\n")
                        out.flush()
                        raise EOFError
                    continue  # ignore mid-input
                if ch in _BACKSPACES:
                    if chars:
                        chars.pop()
                        out.write("\b \b")
                        out.flush()
                    continue
                if ch < " ":  # other control characters (tab, escape, ...)
                    continue
                chars.append(ch)
                out.write("*")
                out.flush()


def should_prompt_password_change(
    *, tunnel_will_start: bool, requires_change: bool, stdin_isatty: bool, stderr_isatty: bool
) -> bool:
    """Whether to block startup on an interactive terminal password change.

    True only when the tunnel is actually about to start, the admin still has
    the seeded password, and both stdin and stderr are real terminals (headless
    launches keep the bootstrap-timeout protection instead of hanging).
    """
    return tunnel_will_start and requires_change and stdin_isatty and stderr_isatty


def prompt_for_password_change(
    *,
    min_length: int,
    is_current_password: Callable[[str], bool],
    apply_change: Callable[[str], None],
    username: str = "unsloth",
    out: "TextIO | None" = None,
) -> bool:
    """Force a new admin password before public exposure; True on success.

    Loops until a valid, confirmed password is committed via ``apply_change``.
    Ctrl-C / EOF returns False; the caller must then abort the launch.
    """
    if out is None:
        out = sys.stderr
    out.write(
        "\n"
        "Unsloth Studio will be exposed on the public internet, so set a\n"
        "password now. Ctrl+C to abort.\n\n"
    )
    out.flush()
    try:
        while True:
            new_password = _read_password("New password: ", out = out)
            if len(new_password) < min_length:
                out.write(f"Password must be at least {min_length} characters; try again.\n")
                out.flush()
                continue
            if any(ch.isspace() for ch in new_password):
                out.write("Password cannot contain spaces; try again.\n")
                out.flush()
                continue
            if is_current_password(new_password):
                out.write(
                    "New password must differ from the current bootstrap password; try again.\n"
                )
                out.flush()
                continue
            confirmation = _read_password("Confirm new password: ", out = out)
            if confirmation != new_password:
                out.write("Passwords do not match; try again.\n")
                out.flush()
                continue
            apply_change(new_password)
            out.write(f"Password updated for '{username}'.\n")
            out.flush()
            return True
    except (KeyboardInterrupt, EOFError):
        out.write("Password change aborted; not exposing Unsloth.\n")
        out.flush()
        return False


def resolve_supplied_password(cli_value: "str | None", out: "TextIO | None" = None) -> "str | None":
    """Resolve a non-interactive initial admin password, or None if unset.

    Precedence: an explicit ``--password`` (literal ``-`` reads a line from
    stdin), then the ``UNSLOTH_STUDIO_PASSWORD`` env var; empty/omitted means off.
    A literal argv value is visible in the process list, so a note points at the
    env var or stdin instead. Mirror of the CLI helper -- keep the two in sync.
    """
    if out is None:
        out = sys.stderr
    if cli_value == "-":
        line = sys.stdin.readline()
        if not line:
            return None
        return line.rstrip("\r\n") or None
    if cli_value:
        out.write(
            "Note: --password is visible in the process list and shell history; "
            f"prefer {SUPPLIED_PASSWORD_ENV} or --password - (stdin).\n"
        )
        out.flush()
        return cli_value
    return os.environ.get(SUPPLIED_PASSWORD_ENV) or None
