"""Terminal chat-frame runtime helpers."""

from __future__ import annotations

import queue
import select
import shutil
import sys
import termios
import threading
import time
import tty
from dataclasses import dataclass
from typing import Callable
from typing import Any

from nipux_cli.settings import inline_setting_notice
from nipux_cli.tui_commands import CHAT_SLASH_COMMANDS, autocomplete_slash, cycle_slash, slash_completion_for_submit
from nipux_cli.tui_input import (
    decode_terminal_escape,
    drain_pending_input,
    read_escape_sequence,
    read_terminal_char,
)
from nipux_cli.tui_outcomes import CHAT_RIGHT_PAGES
from nipux_cli.tui_style import _frame_enter_sequence, _frame_exit_sequence, _one_line, _strip_ansi


IDLE_REFRESH_SECONDS = 0.75
ACTIVE_INPUT_REFRESH_SECONDS = 2.0
THINKING_REFRESH_SECONDS = 0.18
WAITING_REFRESH_SECONDS = 1.25
IDLE_INPUT_POLL_SECONDS = 0.25
ACTIVE_INPUT_POLL_SECONDS = 0.05
WORKSPACE_CHAT_ID = "__workspace__"
THINKING_NOTICE = "__nipux_thinking__"
THINKING_FRAMES = ("◐ thinking", "◓ thinking", "◑ thinking", "◒ thinking")
WAITING_NOTICE = "__nipux_waiting__"
WAITING_FRAMES = ("∙ waiting", "· waiting", "• waiting", "· waiting")


@dataclass(frozen=True)
class ChatFrameDeps:
    load_snapshot: Callable[[str, int], dict[str, Any]]
    render_frame: Callable[[dict[str, Any], str, list[str], str, int, str | None, str | None, str], str]
    handle_chat_message: Callable[[str, str], tuple[bool, str]]
    capture_chat_command: Callable[[str, str], tuple[bool, str]]
    write_shell_state: Callable[[dict[str, str]], None]
    is_plain_chat_line: Callable[[str], bool]
    page_click: Callable[[int, int, str], str | None]


def compact_command_output(output: str) -> list[str]:
    lines = [" ".join(line.split()) for line in output.splitlines() if line.strip()]
    compacted: list[str] = []
    for line in lines:
        if line.startswith("\033[2J"):
            continue
        compacted.append(_one_line(line, 120))
    return compacted[-8:]


def frame_next_job_id(snapshot: dict[str, Any], current_job_id: str, *, direction: int) -> str | None:
    jobs = snapshot.get("jobs")
    if not isinstance(jobs, list) or not jobs:
        return None
    ids = [str(job.get("id")) for job in jobs if job.get("id")]
    if not ids:
        return None
    try:
        index = ids.index(str(current_job_id))
    except ValueError:
        index = 0
    return ids[(index + direction) % len(ids)]


def next_chat_right_view(current: str, direction: int) -> str:
    keys = [key for key, _label in CHAT_RIGHT_PAGES]
    try:
        index = keys.index(current)
    except ValueError:
        index = 0
    return keys[(index + direction) % len(keys)]


def frame_refresh_interval(input_buffer: str, *, thinking: bool = False, waiting: bool = False) -> float:
    if thinking:
        return THINKING_REFRESH_SECONDS
    if waiting:
        return WAITING_REFRESH_SECONDS
    return ACTIVE_INPUT_REFRESH_SECONDS if input_buffer else IDLE_REFRESH_SECONDS


def run_chat_frame(job_id: str, *, history_limit: int, deps: ChatFrameDeps) -> None:
    if job_id != WORKSPACE_CHAT_ID:
        deps.write_shell_state({"focus_job_id": job_id})
    buffer = ""
    notices: list[str] = []
    right_view = "updates"
    modal_view: str | None = None
    selected_control = 0
    editing_field: str | None = None
    async_messages: queue.Queue[str] = queue.Queue()
    snapshot = deps.load_snapshot(job_id, history_limit)
    job_id = str(snapshot["job_id"])
    old_attrs = termios.tcgetattr(sys.stdin)
    print(_frame_enter_sequence(), end="", flush=True)
    try:
        stdin_fd = sys.stdin.fileno()
        tty.setcbreak(stdin_fd)
        last_snapshot = 0.0
        needs_render = True
        last_frame = ""
        while True:
            now = time.monotonic()
            if _drain_async_notices(async_messages, notices):
                last_snapshot = 0.0
                needs_render = True
            refresh_interval = frame_refresh_interval(
                buffer,
                thinking=_has_thinking_notice(notices),
                waiting=_has_waiting_notice(notices),
            )
            if now - last_snapshot >= refresh_interval:
                try:
                    snapshot = deps.load_snapshot(job_id, history_limit)
                    job_id = str(snapshot["job_id"])
                    last_snapshot = now
                    needs_render = True
                except Exception as exc:
                    _append_notice(notices, f"frame refresh failed: {type(exc).__name__}")
            if needs_render:
                selected_control = 0
                last_frame = _safe_render_frame(
                    deps,
                    snapshot=snapshot,
                    buffer=buffer,
                    notices=notices,
                    right_view=right_view,
                    selected_control=selected_control,
                    editing_field=editing_field,
                    modal_view=modal_view,
                    previous_frame=last_frame,
                )
                needs_render = False
            try:
                elapsed = time.monotonic() - last_snapshot
                until_refresh = max(0.01, refresh_interval - elapsed)
                poll_timeout = ACTIVE_INPUT_POLL_SECONDS if buffer else IDLE_INPUT_POLL_SECONDS
                readable, _, _ = select.select([stdin_fd], [], [], min(poll_timeout, until_refresh))
            except OSError as exc:
                _append_notice(notices, f"terminal read failed: {type(exc).__name__}: {_one_line(exc, 90)}")
                needs_render = True
                continue
            if not readable:
                continue
            try:
                char = read_terminal_char(stdin_fd)
            except OSError as exc:
                _append_notice(notices, f"terminal input failed: {type(exc).__name__}: {_one_line(exc, 90)}")
                needs_render = True
                continue
            if editing_field is not None:
                try:
                    buffer, editing_field, should_exit = _handle_edit_input(
                        char,
                        buffer=buffer,
                        editing_field=editing_field,
                        notices=notices,
                        stdin_fd=stdin_fd,
                    )
                except Exception as exc:
                    buffer = ""
                    editing_field = None
                    _append_notice(notices, f"edit failed: {type(exc).__name__}: {_one_line(exc, 90)}")
                    needs_render = True
                    continue
                if should_exit:
                    return
                needs_render = True
                continue
            if char in {"\r", "\n"}:
                buffer, should_submit = slash_completion_for_submit(buffer, CHAT_SLASH_COMMANDS)
                if not should_submit:
                    needs_render = True
                    continue
                try:
                    keep_running, snapshot, job_id, notices, right_view, modal_view = _handle_chat_submit(
                        buffer,
                        job_id=job_id,
                        history_limit=history_limit,
                        snapshot=snapshot,
                        notices=notices,
                        right_view=right_view,
                        modal_view=modal_view,
                        deps=deps,
                        async_messages=async_messages,
                    )
                except Exception as exc:
                    keep_running = True
                    _append_notice(notices, f"submit failed: {type(exc).__name__}: {_one_line(exc, 100)}")
                buffer = ""
                needs_render = True
                if not keep_running:
                    return
                continue
            if char in {"\x04"}:
                return
            if char == "\x03":
                buffer = ""
                _append_notice(notices, "cancelled input")
                needs_render = True
                continue
            if char == "\x15":
                buffer = ""
                needs_render = True
                continue
            if char in {"\x7f", "\b"}:
                buffer = buffer[:-1]
                needs_render = True
                continue
            if char == "\t":
                try:
                    buffer = autocomplete_slash(buffer, CHAT_SLASH_COMMANDS)
                except Exception as exc:
                    _append_notice(notices, f"autocomplete failed: {type(exc).__name__}: {_one_line(exc, 90)}")
                needs_render = True
                continue
            if char == "\x1b":
                try:
                    snapshot, job_id, right_view, modal_view, buffer = _handle_chat_escape(
                        stdin_fd,
                        snapshot=snapshot,
                        job_id=job_id,
                        history_limit=history_limit,
                        right_view=right_view,
                        modal_view=modal_view,
                        buffer=buffer,
                        notices=notices,
                        deps=deps,
                    )
                except Exception as exc:
                    modal_view = None
                    _append_notice(notices, f"navigation failed: {type(exc).__name__}: {_one_line(exc, 90)}")
                needs_render = True
                continue
            if char.isprintable():
                buffer += char
                needs_render = True
    except KeyboardInterrupt:
        return
    finally:
        termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_attrs)
        print(_frame_exit_sequence(), flush=True)


def emit_frame_if_changed(frame: str, previous_frame: str = "") -> str:
    if frame != previous_frame:
        if not previous_frame:
            print("\033[H" + frame, end="", flush=True)
        else:
            print(_diff_frame_update(frame, previous_frame), end="", flush=True)
    return frame


def _safe_render_frame(
    deps: ChatFrameDeps,
    *,
    snapshot: dict[str, Any],
    buffer: str,
    notices: list[str],
    right_view: str,
    selected_control: int,
    editing_field: str | None,
    modal_view: str | None,
    previous_frame: str,
) -> str:
    try:
        return deps.render_frame(
            snapshot,
            buffer,
            _display_notices(notices),
            right_view,
            selected_control,
            editing_field,
            modal_view,
            previous_frame,
        )
    except Exception as exc:
        _append_notice(notices, f"render failed: {type(exc).__name__}: {_one_line(exc, 100)}")
        frame = _fallback_chat_frame(snapshot=snapshot, buffer=buffer, notices=notices)
        print("\033[H" + frame, end="", flush=True)
        return frame


def _fallback_chat_frame(*, snapshot: dict[str, Any], buffer: str, notices: list[str]) -> str:
    width, height = shutil.get_terminal_size((100, 30))
    width = max(60, width)
    job = snapshot.get("job") if isinstance(snapshot.get("job"), dict) else {}
    title = str(job.get("title") or snapshot.get("job_id") or "Nipux")
    lines = [
        _fit_plain("NIPUX - safe mode", width),
        _fit_plain("=" * width, width),
        _fit_plain(f"Job: {title}", width),
        _fit_plain("A UI render error was caught. You can keep typing; /exit leaves.", width),
        "",
        "Recent notices:",
    ]
    lines.extend(f"- {_one_line(notice, width - 3)}" for notice in notices[-8:])
    lines.extend(["", f"> {_one_line(buffer, width - 3)}"])
    return "\n".join(_fit_plain(line, width) for line in lines[:height])


def _diff_frame_update(frame: str, previous_frame: str) -> str:
    current_lines = frame.splitlines()
    previous_lines = previous_frame.splitlines()
    output: list[str] = []
    max_lines = max(len(current_lines), len(previous_lines))
    for index in range(max_lines):
        current = current_lines[index] if index < len(current_lines) else ""
        previous = previous_lines[index] if index < len(previous_lines) else ""
        if current == previous:
            continue
        output.append(f"\033[{index + 1};1H\033[2K{current}")
    return "".join(output)


def _fit_plain(text: Any, width: int) -> str:
    content = _strip_ansi(str(text))
    if len(content) > width:
        content = _one_line(content, width)
    return content + " " * max(0, width - len(content))


def _append_notice(notices: list[str], message: str, *, limit: int = 12) -> None:
    notices.append(message)
    notices[:] = notices[-limit:]


def _append_thinking_notice(notices: list[str]) -> None:
    if not _has_thinking_notice(notices):
        _append_notice(notices, THINKING_NOTICE)


def _append_waiting_notice(notices: list[str]) -> None:
    if not _has_waiting_notice(notices):
        _append_notice(notices, WAITING_NOTICE)


def _has_thinking_notice(notices: list[str]) -> bool:
    return any(notice == THINKING_NOTICE or notice.startswith(f"{THINKING_NOTICE}:") for notice in notices)


def _has_waiting_notice(notices: list[str]) -> bool:
    return any(notice == WAITING_NOTICE or notice.startswith(f"{WAITING_NOTICE}:") for notice in notices)


def _has_active_state_notice(notices: list[str]) -> bool:
    return _has_thinking_notice(notices) or _has_waiting_notice(notices)


def _clear_thinking_notices(notices: list[str]) -> None:
    notices[:] = [
        notice
        for notice in notices
        if notice != THINKING_NOTICE and not notice.startswith(f"{THINKING_NOTICE}:")
    ]


def _clear_waiting_notices(notices: list[str]) -> None:
    notices[:] = [
        notice
        for notice in notices
        if notice != WAITING_NOTICE and not notice.startswith(f"{WAITING_NOTICE}:")
    ]


def _display_notices(notices: list[str]) -> list[str]:
    if not notices:
        return []
    index = int(time.monotonic() / THINKING_REFRESH_SECONDS)
    thinking_frame = THINKING_FRAMES[index % len(THINKING_FRAMES)]
    waiting_frame = WAITING_FRAMES[index % len(WAITING_FRAMES)]
    rendered = []
    for notice in notices:
        if notice == THINKING_NOTICE:
            rendered.append(f"{THINKING_NOTICE}:{thinking_frame}")
        elif notice == WAITING_NOTICE:
            rendered.append(f"{WAITING_NOTICE}:{waiting_frame}")
        else:
            rendered.append(notice)
    return rendered


def _handle_edit_input(
    char: str,
    *,
    buffer: str,
    editing_field: str,
    notices: list[str],
    stdin_fd: int,
) -> tuple[str, str | None, bool]:
    if char in {"\r", "\n"}:
        _append_notice(notices, inline_setting_notice(editing_field, buffer))
        return "", None, False
    if char in {"\x04"}:
        return buffer, editing_field, True
    if char == "\x03":
        _append_notice(notices, "cancelled edit")
        return "", None, False
    if char == "\x15":
        return "", editing_field, False
    if char in {"\x7f", "\b"}:
        return buffer[:-1], editing_field, False
    if char == "\x1b":
        key, _payload = decode_terminal_escape(read_escape_sequence(char, fd=stdin_fd))
        if key == "unknown":
            _append_notice(notices, "cancelled edit")
            return "", None, False
        return buffer, editing_field, False
    if char.isprintable():
        return buffer + char, editing_field, False
    return buffer, editing_field, False


def _handle_chat_submit(
    buffer: str,
    *,
    job_id: str,
    history_limit: int,
    snapshot: dict[str, Any],
    notices: list[str],
    right_view: str,
    modal_view: str | None,
    deps: ChatFrameDeps,
    async_messages: queue.Queue[str] | None = None,
) -> tuple[bool, dict[str, Any], str, list[str], str, str | None]:
    line = buffer.strip()
    if not line:
        return True, snapshot, job_id, notices, right_view, modal_view
    if line in {"clear", "/clear"}:
        notices.clear()
        return True, snapshot, job_id, notices, right_view, None
    if line in {"settings", "/settings"}:
        _append_notice(notices, "opened settings")
        return True, snapshot, job_id, notices, right_view, "settings"
    if line in {"jobs", "/jobs", "status", "/status"}:
        _append_notice(notices, "opened jobs")
        return True, snapshot, job_id, notices, "status", None
    if line in {"outcomes", "/outcomes", "updates", "/updates"}:
        _append_notice(notices, "opened outcomes")
        return True, snapshot, job_id, notices, "updates", None
    keep_running = True
    try:
        if deps.is_plain_chat_line(line):
            _clear_waiting_notices(notices)
            _append_thinking_notice(notices)
            _start_chat_message_worker(
                job_id,
                line,
                deps=deps,
                async_messages=async_messages,
            )
            modal_view = None
        else:
            _append_notice(notices, f"> {line}")
            keep_running, output = deps.capture_chat_command(job_id, line)
            output_lines = compact_command_output(output)
            if output_lines:
                output_text = "\n".join(output_lines)
                if _looks_like_waiting_output(output_text):
                    _append_waiting_notice(notices)
                else:
                    _append_notice(notices, output_text)
            if line.startswith(("/model", "/base-url", "/api-key", "/api-key-env", "/context", "/input-cost", "/output-cost", "/timeout", "/home", "/step-limit", "/output-chars", "/daily-digest", "/digest-time", "/config")):
                modal_view = "settings"
            else:
                modal_view = None
    except Exception as exc:
        _append_notice(notices, f"message failed: {type(exc).__name__}: {_one_line(exc, 120)}")
    try:
        refresh_job_id = _post_submit_snapshot_job_id(line, job_id)
        snapshot = deps.load_snapshot(refresh_job_id, history_limit)
        job_id = str(snapshot["job_id"])
    except Exception as exc:
        _append_notice(notices, f"refresh failed after message: {type(exc).__name__}: {_one_line(exc, 100)}")
    return keep_running, snapshot, job_id, notices, right_view, modal_view


def _post_submit_snapshot_job_id(line: str, current_job_id: str) -> str:
    """Return the job id to refresh after a submitted command or message."""

    text = line.strip()
    if not text.startswith("/"):
        return current_job_id
    command = text[1:].split(maxsplit=1)[0].lower()
    if command in {"new", "focus", "switch"}:
        return WORKSPACE_CHAT_ID if current_job_id == WORKSPACE_CHAT_ID else ""
    return current_job_id


def _start_chat_message_worker(
    job_id: str,
    line: str,
    *,
    deps: ChatFrameDeps,
    async_messages: queue.Queue[str] | None,
) -> None:
    def run() -> None:
        try:
            _keep_running, output = deps.handle_chat_message(job_id, line)
            if async_messages is not None:
                async_messages.put(str(output or "") or "__refresh__")
        except Exception as exc:
            if async_messages is not None:
                async_messages.put(f"message failed: {type(exc).__name__}: {_one_line(exc, 120)}")

    thread = threading.Thread(target=run, name="nipux-chat-submit", daemon=True)
    thread.start()


def _drain_async_notices(async_messages: queue.Queue[str], notices: list[str]) -> bool:
    changed = False
    while True:
        try:
            message = async_messages.get_nowait()
        except queue.Empty:
            return changed
        if message:
            if message == "__refresh__":
                _clear_thinking_notices(notices)
                _clear_waiting_notices(notices)
                changed = True
                continue
            _clear_thinking_notices(notices)
            if _looks_like_waiting_output(message):
                _append_waiting_notice(notices)
            else:
                _clear_waiting_notices(notices)
                _append_notice(notices, message)
            changed = True


def _looks_like_waiting_output(message: str) -> bool:
    normalized = " ".join(str(message or "").lower().split())
    if not normalized:
        return False
    return (
        normalized.startswith("waiting:")
        or normalized.startswith("waiting for ")
        or "waiting for model" in normalized
        or "waiting for the next worker step" in normalized
        or "message saved for the worker" in normalized
    )


def _handle_chat_escape(
    stdin_fd: int,
    *,
    snapshot: dict[str, Any],
    job_id: str,
    history_limit: int,
    right_view: str,
    modal_view: str | None,
    buffer: str,
    notices: list[str],
    deps: ChatFrameDeps,
) -> tuple[dict[str, Any], str, str, str | None, str]:
    key, payload = decode_terminal_escape(read_escape_sequence("\x1b", fd=stdin_fd))
    if modal_view:
        _append_notice(notices, "closed settings")
        drain_pending_input(stdin_fd)
        return snapshot, job_id, right_view, None, buffer
    if key in {"up", "down"} and buffer.startswith("/"):
        buffer = cycle_slash(buffer, CHAT_SLASH_COMMANDS, direction=-1 if key == "up" else 1)
        return snapshot, job_id, right_view, modal_view, buffer
    if key == "right" and not buffer:
        return snapshot, job_id, next_chat_right_view(right_view, 1), modal_view, buffer
    if key == "left" and not buffer:
        return snapshot, job_id, next_chat_right_view(right_view, -1), modal_view, buffer
    if key in {"up", "down"} and not buffer:
        next_focus = frame_next_job_id(snapshot, job_id, direction=-1 if key == "up" else 1)
        if next_focus and next_focus != job_id:
            job_id = next_focus
            deps.write_shell_state({"focus_job_id": job_id})
            snapshot = deps.load_snapshot(job_id, history_limit)
            title = snapshot["job"].get("title") or job_id
            _append_notice(notices, f"focus {title}")
        return snapshot, job_id, right_view, modal_view, buffer
    if key == "click" and isinstance(payload, tuple):
        clicked_view = deps.page_click(payload[0], payload[1], right_view)
        if clicked_view:
            return snapshot, job_id, clicked_view, modal_view, buffer
    drain_pending_input(stdin_fd)
    return snapshot, job_id, right_view, modal_view, buffer
