from __future__ import annotations

import os
import select
import shutil
import textwrap
from dataclasses import dataclass
from typing import Generic, Sequence, TextIO, TypeVar

try:
    import termios
    import tty
except ImportError:  # pragma: no cover - non-POSIX fallback
    termios = None
    tty = None


T = TypeVar("T")
RAW_TTY_NEWLINE = "\r\n"
# Give terminal escape sequences a slightly roomier window so arrow keys
# still parse correctly when an outer Node/npm launcher adds a bit of PTY lag.
ESCAPE_SEQUENCE_TIMEOUT_SECONDS = 0.2
ESCAPE_SEQUENCE_FOLLOWUP_TIMEOUT_SECONDS = 0.02


@dataclass(slots=True)
class SelectionOption(Generic[T]):
    value: T
    label: str
    hint: str | None = None


def select_option(
    *,
    title: str,
    options: Sequence[SelectionOption[T]],
    input_stream: TextIO,
    output_stream: TextIO,
    page_size: int = 8,
) -> T | None:
    if not options:
        return None
    if len(options) == 1:
        return options[0].value
    if not _supports_raw_selection(input_stream=input_stream, output_stream=output_stream):
        return _select_option_via_prompt(title=title, options=options, input_stream=input_stream, output_stream=output_stream)
    return _select_option_via_raw_terminal(
        title=title,
        options=options,
        input_stream=input_stream,
        output_stream=output_stream,
        page_size=max(3, page_size),
    )


def _supports_raw_selection(*, input_stream: TextIO, output_stream: TextIO) -> bool:
    if termios is None or tty is None:
        return False
    if not bool(getattr(input_stream, "isatty", lambda: False)()) or not bool(getattr(output_stream, "isatty", lambda: False)()):
        return False
    return hasattr(input_stream, "fileno")


def _select_option_via_prompt(
    *,
    title: str,
    options: Sequence[SelectionOption[T]],
    input_stream: TextIO,
    output_stream: TextIO,
) -> T | None:
    output_stream.write(title + "\n")
    for index, option in enumerate(options, start=1):
        suffix = f" ({option.hint})" if option.hint else ""
        output_stream.write(f"{index}. {option.label}{suffix}\n")
    output_stream.write("请输入编号并回车，留空取消: ")
    output_stream.flush()
    line = input_stream.readline()
    selected = str(line or "").strip()
    if not selected:
        return None
    if not selected.isdigit():
        return None
    index = int(selected) - 1
    if index < 0 or index >= len(options):
        return None
    return options[index].value


def _select_option_via_raw_terminal(
    *,
    title: str,
    options: Sequence[SelectionOption[T]],
    input_stream: TextIO,
    output_stream: TextIO,
    page_size: int,
) -> T | None:
    fd = input_stream.fileno()
    original_mode = termios.tcgetattr(fd)
    selected_index = 0
    output_stream.write("\x1b[?1049h\x1b[?25l")
    output_stream.flush()
    try:
        tty.setraw(fd)
        while True:
            _render_options(
                title=title,
                options=options,
                selected_index=selected_index,
                output_stream=output_stream,
                page_size=page_size,
            )
            key = _read_key(input_stream)
            if key in ("\r", "\n"):
                return options[selected_index].value
            if key in ("\x03", "\x1b", "q", "Q"):
                return None
            if key in ("\x1b[A", "k", "K"):
                selected_index = (selected_index - 1) % len(options)
                continue
            if key in ("\x1b[B", "j", "J"):
                selected_index = (selected_index + 1) % len(options)
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, original_mode)
        output_stream.write("\x1b[?25h\x1b[?1049l")
        output_stream.flush()


def _render_options(
    *,
    title: str,
    options: Sequence[SelectionOption[object]],
    selected_index: int,
    output_stream: TextIO,
    page_size: int,
) -> None:
    terminal_width = shutil.get_terminal_size((100, 20)).columns
    total = len(options)
    page_start = max(0, min(selected_index - page_size // 2, max(total - page_size, 0)))
    visible = options[page_start: page_start + page_size]
    lines = _render_multiline_text(title, width=terminal_width)
    lines.extend(
        [
            "↑/↓ 或 j/k 选择，Enter 确认，q / Esc 取消",
            "",
        ]
    )
    for offset, option in enumerate(visible, start=page_start):
        marker = ">" if offset == selected_index else " "
        suffix = f"  [{option.hint}]" if option.hint else ""
        lines.append(_truncate_line(f"{marker} {option.label}{suffix}", width=terminal_width))
    if total > page_size:
        lines.append("")
        lines.append(f"{selected_index + 1}/{total}")
    output_stream.write("\x1b[2J\x1b[H")
    output_stream.write(RAW_TTY_NEWLINE.join(lines))
    output_stream.flush()


def _truncate_line(text: str, *, width: int) -> str:
    if width <= 0 or len(text) <= width:
        return text
    if width <= 1:
        return text[:width]
    return text[: width - 1] + "…"


def _render_multiline_text(text: str, *, width: int) -> list[str]:
    parts = text.splitlines() or [text]
    rendered: list[str] = []
    wrap_width = max(1, width)
    for part in parts:
        if not part:
            rendered.append("")
            continue
        initial_indent = ""
        subsequent_indent = ""
        stripped = part.lstrip()
        if stripped.startswith("- "):
            leading_spaces = len(part) - len(stripped)
            initial_indent = part[:leading_spaces] + "- "
            subsequent_indent = part[:leading_spaces] + "  "
            content = stripped[2:]
        else:
            content = part
        wrapped = textwrap.wrap(
            content,
            width=wrap_width,
            initial_indent=initial_indent,
            subsequent_indent=subsequent_indent,
            replace_whitespace=False,
            drop_whitespace=False,
            break_long_words=True,
        )
        rendered.extend(wrapped or [""])
    return rendered


def _read_key(input_stream: TextIO) -> str:
    fd = input_stream.fileno()
    first_bytes = os.read(fd, 1)
    if not first_bytes:
        return ""
    first = first_bytes.decode("utf-8", errors="ignore")
    if first != "\x1b":
        return first
    chunks = [first]
    if not select.select([fd], [], [], ESCAPE_SEQUENCE_TIMEOUT_SECONDS)[0]:
        return first
    while True:
        chunk = os.read(fd, 1).decode("utf-8", errors="ignore")
        if not chunk:
            break
        chunks.append(chunk)
        if len(chunks) >= 3:
            break
        if not select.select([fd], [], [], ESCAPE_SEQUENCE_FOLLOWUP_TIMEOUT_SECONDS)[0]:
            break
    return "".join(chunks)
