#!/usr/bin/env python3
# Miracle Claw (miracle_claw)
# Copyright (c) 2026 Milagro Distribution Corp.
# All rights reserved. See LICENSE file for terms.
"""mlg — Milagro Claw: a talk-to-LLM-and-run-code shell.

Usage:
    mlg                       Start interactive REPL
    mlg "list .txt files"     One-shot mode (single question, run, exit)
    mlg --model NAME          Override model (default: milagro-dev)
    mlg --url URL             Override MAIC endpoint
    mlg --auto                Auto-run read-only (SAFE) commands without asking
    mlg --yes / -y            Auto-run SAFE + NORMAL without asking
                             (DANGEROUS still requires YES-EXEC)
    mlg --no-stream           Disable streaming response
    mlg --no-context          Skip project context injection
    mlg --no-memory           Skip cross-session memory injection
    mlg --help                Show this help

Environment:
    MILAGRO_API_KEY    Bearer token for MAIC gateway (required)
    MIRACLE_CLAW_HOME  Override config dir (default: ~/.miracle_claw)

Architecture:
    mlg (this file) — entry point + REPL loop only.
    ui.py           — ANSI color + banner.
    runtime.py      — language runners (python/bash/perl).
    llm.py          — MAIC gateway client + JSON envelope parsing.
    context.py      — dir snapshot + project context snippets.
    commands.py     — slash command dispatch + LLM turn.
    history.py      — JSONL log read/write + cross-session memory.
    repl.py         — line editor (readline replacement).
    safety.py       — risk classifier for shell commands.
    completion.py   — tab completion.
"""
from __future__ import annotations
import argparse
import atexit
import os
import pathlib
import signal
import struct
import sys
import urllib.error
try:
    import fcntl
    import termios
    _HAVE_TIOCGWINSZ = True
except ImportError:
    _HAVE_TIOCGWINSZ = False
try:
    _TTY_FALLBACK_ROWS = int(os.environ.get("LINES", "24"))
    _TTY_FALLBACK_COLS = int(os.environ.get("COLUMNS", "80"))
except ValueError:
    _TTY_FALLBACK_ROWS, _TTY_FALLBACK_COLS = 24, 80

# Allow running `python3 mlg` from anywhere.
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))

# If a voice_venv sits next to this script, prepend its site-packages to
# sys.path so the lazy `import faster_whisper` inside commands.cmd_voice
# works without re-execing mlg inside the venv. Cheap and reversible.
_VOICE_VENV_SP = HERE / "voice_venv" / "lib" / f"python{sys.version_info.major}.{sys.version_info.minor}" / "site-packages"
if _VOICE_VENV_SP.is_dir() and str(_VOICE_VENV_SP) not in sys.path:
    sys.path.insert(0, str(_VOICE_VENV_SP))

from ui import color, C, draw_footer, footer_row1, footer_row2
from llm import (
    DEFAULT_URL, DEFAULT_MODEL, REQUEST_TIMEOUT,
    load_system_prompt,
)
from commands import handle_turn, handle_repl_line, HELP_TEXT
from context import project_context
from history import recent_messages
from repl import LineEditor, CompletionAdapter, RawMode, CANCEL
import auth  # MAIC user-auth client (Phase 1)





def _term_size() -> tuple[int, int]:
    """Return (rows, cols) of the controlling terminal. Falls back to
    $LINES/$COLUMNS env vars, then 24x80."""
    if _HAVE_TIOCGWINSZ:
        try:
            buf = struct.pack("HHHH", 0, 0, 0, 0)
            buf = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, buf)
            rows, cols, _, _ = struct.unpack("HHHH", buf)
            if rows > 0 and cols > 0:
                return rows, cols
        except (OSError, ValueError):
            pass
    return _TTY_FALLBACK_ROWS, _TTY_FALLBACK_COLS


def _build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="mlg",
        description="Milagro Claw: talk-to-LLM-and-run-code shell.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=HELP_TEXT,
    )
    p.add_argument("question", nargs="*", help="One-shot question (skips REPL)")
    p.add_argument("--model", default=DEFAULT_MODEL, help=f"Model name (default: {DEFAULT_MODEL})")
    p.add_argument("--url",   default=DEFAULT_URL,   help=f"MAIC endpoint (default: {DEFAULT_URL})")
    p.add_argument("--auto", action="store_true", help="Auto-run read-only commands without asking")
    p.add_argument("--yes", "-y", action="store_true",
                   help="Auto-run SAFE and NORMAL commands without confirming. DANGEROUS still asks.")
    p.add_argument("--no-stream", action="store_true", help="Disable streaming response")
    p.add_argument("--no-color", action="store_true", help="Disable colored output")
    p.add_argument("--no-context", action="store_true", help="Skip project context injection")
    p.add_argument("--no-memory", action="store_true", help="Skip cross-session memory injection")
    p.add_argument("--memory-turns", type=int, default=4,
                   help="Number of recent turns to inject as memory (default: 4)")
    p.add_argument("--timeout", type=int, default=REQUEST_TIMEOUT, help="API timeout (seconds)")
    p.add_argument("--no-session", action="store_true",
                   help="Skip loading/saving the persisted session file")
    p.add_argument("--reset-session", action="store_true",
                   help="Clear the persisted session file before starting")
    p.add_argument("--cwd", default=None,
                   help="Starting directory (default: load from session, else $PWD)")
    p.add_argument("--api-key", default=None,
                   help="Override the MAIC API key (escape hatch — bypasses login)")
    p.add_argument("--setup", action="store_true",
                   help="Run the first-run wizard (create admin + login)")
    p.add_argument("--tier", default=None,
                   help="Override active tier (local) for this run")
    p.add_argument("--login", action="store_true",
                   help="Log in to MAIC (interactive; saves session)")
    p.add_argument("--logout", action="store_true",
                   help="Log out of MAIC and exit")
    p.add_argument("--register", action="store_true",
                   help="Create a new MAIC account (interactive)")
    p.add_argument("--change-password", action="store_true",
                   help="Change your MAIC account password (interactive; --force to skip current-password re-prompt when stdin is not a TTY)")
    p.add_argument("--update", action="store_true",
                   help="Upgrade to the latest miracle-claw release via pip")
    p.add_argument("--check-update", action="store_true",
                   help="Show available updates without upgrading")
    p.add_argument("--no-update", action="store_true",
                   help="Skip the automatic startup update check")
    p.add_argument("--no-alt-screen", action="store_true",
                   help="Don't switch to the alternate screen buffer on chat start "
                        "(for terminals/broken setups where 1049 doesn't work right)")
    p.add_argument("--update-channel", default=None, choices=("stable", "dev"),
                   help="Release channel for update checks (default: stable)")
    p.add_argument("--sync", action="store_true",
                   help="Upload unsynced mlg turn logs to MAIC training_pairs, then exit")
    p.add_argument("--sync-status", action="store_true",
                   help="Show how many turns are waiting to be synced, then exit")
    p.add_argument("--sync-limit", type=int, default=200,
                   help="Max turns to upload in one --sync run (default: 200)")
    p.add_argument("--rate", nargs=2, metavar=("REQUEST_ID", "SCORE"),
                   help="Rate a training pair (1, -1, or 0 to clear) and exit")
    p.add_argument("--rate-list", type=int, nargs="?", const=20, metavar="N",
                   help="List the N most recent training pairs and exit (default 20)")

    # ─── Browser UI lifecycle (mlg --serve-*) ────────────────────────
    from serve import add_serve_args
    add_serve_args(p)

    return p


def _check_api_key(args=None) -> str:
    """Resolve the MAIC API key.

    Order of precedence:
      1. --api-key CLI flag (escape hatch for headless / CI use)
      2. MILAGRO_API_KEY env var (legacy; warns loudly)
      3. Per-user mk_*** from the active tier's cached key
         (created at login time, chmod 600, 30-day rotation)
    """
    from tiers import get_active_tier
    tier = get_active_tier()

    # 1. Explicit --api-key flag (escape hatch).
    if args is not None and getattr(args, "api_key", None):
        return args.api_key

    # 3. Tier-cached per-user key (preferred).
    override = tier.api_key()
    if override:
        return override

    # 2. Legacy env var (with warning).
    api_key = os.environ.get("MILAGRO_API_KEY")
    if api_key:
        print("⚠️  MILAGRO_API_KEY is set in the environment.", file=sys.stderr)
        print("    This is a legacy auth path. Login-based auth is preferred.", file=sys.stderr)
        print("    Run `mlg --setup` to create an admin account.", file=sys.stderr)
        return api_key

    # Nothing — explain the situation.
    print("❌  No MAIC API key available.", file=sys.stderr)
    print("    Run:  mlg --setup       (first-run, creates admin)", file=sys.stderr)
    print("    Or:   mlg --login       (log in with existing admin)", file=sys.stderr)
    print("    Or:   mlg --api-key MK_xxx  (headless escape hatch)", file=sys.stderr)
    sys.exit(2)


def _auto_sync_training_data(home: pathlib.Path, url: str, api_key: str,
                              user_id: int, limit: int = 50) -> bool:
    """On REPL exit, push any unsynced turn logs to MAIC.

    Silently no-ops if:
      - MILAGRO_NO_AUTO_SYNC=1 is set
      - The user isn't logged in (no user_id)
      - There are no unsynced turns
      - Any network/import error occurs (we never want auto-sync
        to break the exit flow — it's a nice-to-have, not a contract)

    Returns True if a sync was attempted (whether or not it succeeded).
    Callers can use the return value to dedupe with the atexit handler
    (which fires on every exit, including the ones we explicitly call
    this from).
    """
    # Opt-out check.
    if os.environ.get("MILAGRO_NO_AUTO_SYNC", "").strip() in ("1", "true", "yes"):
        return False
    if not user_id:
        # Master-key / un-logged-in sessions don't have a user_id to
        # tag uploads with. Skip rather than upload anonymous rows.
        return False
    if not api_key:
        return False
    try:
        from training_sync import count_unsynced, sync_unsynced
        logs_dir = home / "logs"
        if not logs_dir.is_dir():
            return False  # Never used a --yes run, or logs dir deleted.
        n = count_unsynced(logs_dir)
        if n == 0:
            return False
        result = sync_unsynced(logs_dir, url, api_key, user_id, limit=limit)
        uploaded = result.get("uploaded", 0)
        if uploaded > 0:
            print(color(
                f"⚡  synced {uploaded} turn{'s' if uploaded != 1 else ''} "
                f"to MAIC training_pairs",
                C.DIM,
            ), file=sys.stderr)
        return True
    except Exception as e:
        # Never crash the exit flow on a sync error.
        # Surface the error only if debug is on, otherwise stay silent.
        if os.environ.get("MILAGRO_DEBUG", "").strip() in ("1", "true", "yes"):
            print(color(f"  (auto-sync failed: {type(e).__name__}: {e})",
                        C.YELLOW), file=sys.stderr)
        return False


def _build_context(args, cwd, history_dir) -> tuple[str, list[dict]]:
    """Build system prompt + project context + memory once at startup."""
    system_prompt = load_system_prompt()

    proj_ctx = ""
    if not args.no_context:
        try:
            proj_ctx = project_context(cwd)
        except Exception as e:
            print(color(f"  ⚠️  could not build project context: {e}", C.YELLOW))

    memory: list[dict] = []
    if not args.no_memory:
        try:
            memory = recent_messages(history_dir, limit=args.memory_turns * 2)
        except Exception as e:
            print(color(f"  ⚠️  could not load memory: {e}", C.YELLOW))

    return system_prompt, proj_ctx, memory


def main() -> None:
    p = _build_arg_parser()
    args = p.parse_args()

    # ─── Serve subcommands (no login, no API key, no update check) ───
    from serve import dispatch_serve
    rc = dispatch_serve(args)
    if rc is not None:
        sys.exit(rc)

    # ─── Auto-update pre-flight (must run before tier/login to avoid
    #     upgrading mid-wizard) ─────────────────────────────────────────
    import update as _update
    # Honour the explicit CLI flags.
    if args.update:
        sys.exit(_update.perform_update())
    if args.check_update:
        sys.exit(_update.main(["--check-update"]))
    # Otherwise: lightweight lazy check, gated by env flag.
    if (not args.no_update
            and not os.environ.get("MILAGRO_NO_UPDATE_CHECK")
            and "--help" not in sys.argv):
        try:
            channel = args.update_channel or os.environ.get("MILAGRO_UPDATE_CHANNEL", "stable")
            info = _update.check_for_update(channel, use_cache=True)
            if info:
                msg = _update.format_update_message(info)
                if msg:
                    print(color(msg, C.YELLOW), file=sys.stderr)
        except Exception:
            # Update checks must never crash mlg.
            pass

    # ─── Tier + auth pre-flight (handle flags before key check) ─────
    if args.tier:
        from tiers import set_active_tier
        try:
            set_active_tier(args.tier)
        except ValueError as e:
            print(f"❌  {e}", file=sys.stderr)
            sys.exit(2)
    if args.logout:
        import auth as _auth
        _auth.logout()
        print("✅  Logged out.")
        sys.exit(0)

    # ─── Sync training data to MAIC (no LLM call needed) ─────────
    if args.sync or args.sync_status:
        from training_sync import sync_unsynced, count_unsynced
        from pathlib import Path
        history_dir = Path(os.environ.get(
            "MILAGRO_CLAW_HOME",
            str(Path.home() / ".miracle_claw")
        )) / "logs"
        if args.sync_status:
            n = count_unsynced(history_dir)
            print(f"⚡  {n} unsynced turn{'s' if n != 1 else ''} waiting in {history_dir}")
            sys.exit(0)
        # args.sync:
        api_key = _check_api_key()
        url = args.url
        # user_id comes from profile (cached at login time).
        from profile import get as _profile_get
        user_id = _profile_get("user_id", 0)
        if not user_id:
            print("❌  Not logged in. Run `mlg --login` first.", file=sys.stderr)
            sys.exit(1)
        print(f"⚡  Syncing up to {args.sync_limit} turns to {url}...")
        result = sync_unsynced(history_dir, url, api_key, user_id, limit=args.sync_limit)
        if "error" in result and "uploaded" in result and result["uploaded"] == 0:
            print(f"  → uploaded {result.get('uploaded', 0)}, "
                  f"failed {result.get('skipped', 0)}: {result.get('error', '')}",
                  file=sys.stderr)
            sys.exit(1)
        print(f"✅  Uploaded {result.get('uploaded', 0)} turn"
              f"{'s' if result.get('uploaded', 0) != 1 else ''}. "
              f"Failed: {result.get('skipped', 0)}.")
        sys.exit(0)

    if args.rate_list is not None:
        from rate import fetch_recent_pairs
        url = args.url
        if not url:
            from tiers import get_active_tier
            url = get_active_tier().maic_url()
        url = url.rstrip("/")
        if url.endswith("/v1"):
            url = url[:-3].rstrip("/")
        api_key = _check_api_key(args)
        try:
            pairs = fetch_recent_pairs(url, api_key, limit=args.rate_list)
        except urllib.error.HTTPError as e:
            body = e.read().decode("utf-8", errors="replace")
            print(f"❌  {body or e.reason}", file=sys.stderr)
            sys.exit(1)
        except Exception as e:
            print(f"❌  {e}", file=sys.stderr)
            sys.exit(1)
        if not pairs:
            print("  (no training pairs yet)")
        else:
            total_id = pairs[0].get("id", "?")
            print(f"Recent training pairs ({len(pairs)} shown, latest id={total_id}):")
            for i, p in enumerate(pairs, 1):
                q = (p.get("question") or "?")[:60]
                score = p.get("score", 0)
                badge = "👍" if score == 1 else ("👎" if score == -1 else "  ")
                rid = p.get("request_id", "?")
                ts = (p.get("created_at") or "")[:19]
                print(f"  {i:3}. {badge} [{ts}] {rid}  {q}")
        sys.exit(0)

    if args.rate:
        from rate import rate_pair
        request_id, score_str = args.rate
        try:
            score = int(score_str)
        except ValueError:
            print(f"❌  score must be an integer: -1, 0, or 1 (got {score_str!r})",
                  file=sys.stderr)
            sys.exit(2)
        if score not in (-1, 0, 1):
            print(f"❌  score must be -1, 0, or 1 (got {score})", file=sys.stderr)
            sys.exit(2)
        url = args.url
        if not url:
            from tiers import get_active_tier
            url = get_active_tier().maic_url()
        # llm.py's DEFAULT_URL has a /v1 suffix — strip it so we don't
        # double-up when we append '/v1/training/rate'.
        url = url.rstrip("/")
        if url.endswith("/v1"):
            url = url[:-3].rstrip("/")
        api_key = _check_api_key(args)
        try:
            r = rate_pair(request_id, score, url, api_key)
        except urllib.error.HTTPError as e:
            body = e.read().decode("utf-8", errors="replace")
            print(f"❌  {body or e.reason}", file=sys.stderr)
            sys.exit(1)
        except Exception as e:
            print(f"❌  {e}", file=sys.stderr)
            sys.exit(1)
        sign = "👍" if r["score"] == 1 else ("👎" if r["score"] == -1 else "∅")
        verb = "updated" if r["updated"] else "unchanged"
        print(f"✅  {sign} {r['request_id']}  (was score={r['previous_score']}, {verb})")
        sys.exit(0)

    if args.login or args.register:
        import auth as _auth
        from wizard import _login_flow, _register_flow
        try:
            if args.register:
                _register_flow()
            else:
                _login_flow()
        except SystemExit:
            raise
        except Exception as e:
            print(f"❌  {e}", file=sys.stderr)
            sys.exit(1)
        # After login/register: switch to local tier if not already.
        from tiers import get_active_tier, set_active_tier
        if get_active_tier().name != "local":
            set_active_tier("local")
        # No question → exit cleanly. Otherwise fall through.
        if not args.question:
            sys.exit(0)
    if args.setup:
        from wizard import run_setup_wizard
        try:
            run_setup_wizard()
        except SystemExit:
            raise
        except Exception as e:
            print(f"❌  setup wizard failed: {e}", file=sys.stderr)
            sys.exit(1)
        if not args.question:
            sys.exit(0)
    if args.change_password:
        import auth as _auth
        import getpass as _gp
        me = _auth.me() or _auth.current_user()
        if not me:
            print("❌  Not logged in. Run `mlg --login` first.", file=sys.stderr)
            sys.exit(1)
        email = me.get("email") or "(unknown)"
        print(f"  Changing password for {email}")
        try:
            if sys.stdin.isatty():
                old = _gp.getpass("  Current password: ")
                new1 = _gp.getpass("  New password: ")
                new2 = _gp.getpass("  Confirm new password: ")
                if new1 != new2:
                    print("❌  Passwords don't match.", file=sys.stderr)
                    sys.exit(1)
                if old == new1:
                    print("❌  New password is the same as the old one.", file=sys.stderr)
                    sys.exit(1)
            else:
                # Non-TTY (piped / CI): read all three lines from stdin.
                lines = [l.rstrip("\n") for l in sys.stdin]
                if len(lines) < 3:
                    print("❌  Need 3 lines on stdin: old, new, confirm.",
                          file=sys.stderr)
                    sys.exit(2)
                old, new1, new2 = lines[0], lines[1], lines[2]
                if new1 != new2:
                    print("❌  Passwords don't match.", file=sys.stderr)
                    sys.exit(1)
                if old == new1:
                    print("❌  New password is the same as the old one.",
                          file=sys.stderr)
                    sys.exit(1)
            result = _auth.change_password(old, new1)
            if result.get("changed"):
                print("✅  Password changed. New session token saved.")
            else:
                print("❌  Server didn't confirm the change.", file=sys.stderr)
                sys.exit(1)
        except RuntimeError as e:
            msg = str(e)
            if "401" in msg or "Current password is incorrect" in msg:
                print("❌  Current password is incorrect.", file=sys.stderr)
                sys.exit(1)
            print(f"❌  {msg}", file=sys.stderr)
            sys.exit(1)
        if not args.question:
            sys.exit(0)

    # ─── Tier-aware setup ─────────────────────────────────────────────
    # Resolve the active tier (single tier: "local"). The tier's boot()
    # checks for a valid session and prompts for login if missing.
    from tiers import get_active_tier
    from wizard import needs_setup
    tier = get_active_tier()
    if needs_setup():
        # First run on this machine. Run the setup wizard.
        from wizard import run_setup_wizard
        run_setup_wizard()
    try:
        tier.boot({})
    except SystemExit:
        raise
    except Exception as e:
        print(f"⚠️  tier boot failed: {e}", file=sys.stderr)
        sys.exit(1)

    api_key = _check_api_key(args)
    color_on = not args.no_color and sys.stdout.isatty()

    home = pathlib.Path(os.environ.get("MIRAGRO_CLAW_HOME", pathlib.Path.home() / ".miracle_claw"))
    history_dir = home / "history"
    history_dir.mkdir(parents=True, exist_ok=True)

    # Resolved user_id (for training data attribution). 0 means
    # master-key / un-logged-in session, in which case auto-sync is
    # a no-op.
    try:
        from profile import get as _profile_get_user_id
        user_id = int(_profile_get_user_id("user_id", 0) or 0)
    except Exception:
        user_id = 0

    # ─── Session persistence (cwd, model, url) ─────────────────────────
    from session import load as session_load, save as session_save, clear as session_clear
    saved = {} if args.reset_session else (session_load() if not args.no_session else {})
    if args.reset_session:
        session_clear()

    # cwd: explicit --cwd > saved cwd > current dir
    if args.cwd:
        cwd = pathlib.Path(args.cwd).expanduser().resolve()
    elif "cwd" in saved:
        cwd = pathlib.Path(saved["cwd"]).expanduser().resolve()
    else:
        cwd = pathlib.Path.cwd()

    system_prompt, proj_ctx, memory = _build_context(args, cwd, history_dir)

    # Org CLAW.md (Phase 2): overrides personal CLAW.md if the org has
    # one set. Org admin owns the rules; personal CLAW.md is only used
    # when no org CLAW.md exists.
    import auth as _auth
    try:
        org_claw = _auth.org_get_setting("claw_md")
        if org_claw:
            system_prompt = org_claw
    except Exception:
        pass

    # Mutable state passed to slash commands.
    # Restored fields: model, url (if not overridden on command line).
    # URL default: tier-aware (customer → Milagro cloud).
    tier_url = tier.maic_url()
    state = {
        "model": saved.get("model") or args.model,
        "url":   saved.get("url")   or args.url   or tier_url or "",
        "auto":  args.auto,
        "yes":   args.yes,
        "stream": not args.no_stream,
        "timeout": args.timeout,
        # Tier (for the greeting and /tier command).
        "tier":  tier.name,
        "user":  auth.current_user() if tier.name != "personal" else None,
        # Token usage — accumulated across turns for the footer HUD.
        # Reset to 0 each session (not persisted — start fresh on reload).
        "total_tokens": 0,
        "last_turn_tokens": 0,
        "last_prompt_tokens": 0,
        "last_completion_tokens": 0,
        "total_prompt_tokens": 0,
        "total_completion_tokens": 0,
    }

    # Auto-sync training data on every exit path. Registered as atexit
    # so it fires from sys.exit(), unhandled exceptions, normal return,
    # and SIGTERM (all of which trigger Python's atexit chain). The
    # explicit calls in the REPL exit branches are belt-and-suspenders
    # for cases where atexit is suppressed (e.g. the WS-bridged REPL
    # calling os._exit()).
    if user_id and api_key:
        _sync_url = state["url"]
        _auto_sync_done = [False]  # mutable flag for dedup
        def _atexit_auto_sync() -> None:
            if _auto_sync_done[0]:
                return  # explicit call already ran
            _auto_sync_training_data(home, _sync_url, api_key, user_id)
        atexit.register(_atexit_auto_sync)
        # Stash on state so REPL exit branches can flip the flag.
        state["_auto_sync_done"] = _auto_sync_done
    # Resolve model: prefer saved resolved name if user didn't override.
    from llm import resolve_model
    if saved.get("model_resolved") and saved.get("model") == state["model"]:
        # The saved model name matches the resolved one in state; trust it.
        state["model_resolved"] = saved["model_resolved"]
        state["model_transport"] = saved.get("model_transport", "maic")
    else:
        _resolved, _transport = resolve_model(state["model"])
        state["model_resolved"] = _resolved
        state["model_transport"] = _transport

    # Persist on normal exit (Ctrl-C / SIGTERM / return). Best-effort.
    def _persist_session() -> None:
        if args.no_session:
            return
        try:
            session_save(state, cwd=str(cwd))
        except Exception as e:
            import sys as _sys
            print(f"⚠️  session save failed: {type(e).__name__}: {e}",
                  file=_sys.stderr)
    atexit.register(_persist_session)

    # Shared context dict for handle_repl_line — anything that's expensive
    # to recompute per-line lives here.
    def _reader(limit: int) -> list[dict]:
        from history import recent_entries
        return recent_entries(history_dir, limit=limit)
    def _clear_chat_region() -> None:
        """Clear the chat scroll region (preserve box + footer).

        In TTY mode: uses DECSTBM scroll region + ED selective erase to
        wipe only the chat area (rows 1..scroll_bottom). The box at the
        bottom and the footer BELOW it are OUTSIDE the scroll region and
        therefore untouched.

        In non-TTY mode (one-shot `--question`): falls back to a full
        screen clear since there's no scroll region set.

        Late-binding lookups for `is_tty` and `layout` are required so
        this function can be registered in ctx before they are defined.
        """
        if not is_tty:  # noqa: F821
            sys.stdout.write("\033[2J\033[H")
            sys.stdout.flush()
            return
        sys.stdout.write("\033[1;1H")     # home (top of scroll region)
        sys.stdout.write("\033[J")        # erase to end of screen (within region)
        sys.stdout.write("\033[1;1H")     # re-home for next prompt
        sys.stdout.flush()

    ctx = {
        "state":         state,
        "system_prompt": system_prompt,
        "api_key":       api_key,
        "color_on":      color_on,
        "cwd":           cwd,
        "history_dir":   history_dir,
        "project_ctx":   proj_ctx,
        "memory":        memory,
        "history_reader": _reader,
        "clear_chat":    _clear_chat_region,
        # Pricing for /cost (USD per 1M tokens). Override with env vars
        # MILAGRO_INPUT_PRICE_PER_M / MILAGRO_OUTPUT_PRICE_PER_M if you're
        # running a different model (e.g. Claude Opus, local Ollama).
        "pricing": {
            "input":  float(os.environ.get("MILAGRO_INPUT_PRICE_PER_M",  "0.50")),
            "output": float(os.environ.get("MILAGRO_OUTPUT_PRICE_PER_M", "1.50")),
        },
    }

    # ─── One-shot mode ──────────────────────────────────────────────
    if args.question:
        question = " ".join(args.question)
        handle_turn(
            question=question,
            system_prompt=system_prompt,
            cwd=cwd,
            model=args.model,
            url=args.url,
            api_key=api_key,
            auto=args.auto,
            yes=args.yes,
            stream=not args.no_stream,
            timeout=args.timeout,
            color_on=color_on,
            history_dir=history_dir,
            project_ctx=proj_ctx,
            memory=memory,
            model_resolved=state.get("model_resolved") or args.model,
            model_transport=state.get("model_transport", "maic"),
        )
        return

    # ─── Alt-screen handoff ─────────────────────────────────────────
    # Before drawing the chat UI over whatever was on the user's terminal,
    # switch to the alternate screen buffer (xterm 1049) so that on exit
    # the previous terminal content is restored *exactly* as we found it.
    # We also clear the new buffer, set a dark background, and reset all
    # SGR attributes — this is the "wipe + paint" that makes the transition
    # unmistakable, matching how OpenClaw and other TUI apps (vim, htop,
    # lazygit, etc.) start their full-screen modes.
    #
    # Skipped when:
    #   * stdout isn't a TTY (piped / CI / mlg-serve — browser handles its
    #     own screen). The alt screen is a real-terminal feature; the
    #     browser path would just see escape soup it can't render.
    #   * `--no-alt-screen` was passed (escape hatch for broken terminals,
    #     or users who want the chat to scroll naturally with their shell
    #     history).
    use_alt = (not getattr(args, "no_alt_screen", False)
               and sys.stdout.isatty()
               and not os.environ.get("MILAGRO_CHAT_SURFACE"))
    if use_alt:
        # 1049h   — switch to alt screen (previous content saved)
        # ?25l    — hide cursor while we lay out
        # 2J      — clear the entire new buffer (any leftover from tmux
        #            pre-output, the user's last command, etc.)
        # 0m      — reset all SGR attributes (bold, underline, color)
        #            so nothing from the prior session bleeds into ours
        # ?1049h's 5h sequence is the "1:1 mapping" guard; 39/49 (default
        # fg/bg) would also work but 0m is more thorough
        # 48;5;236 — set background to #1f1f1f (very dark gray) for the
        #            brief moment before our own background-aware chars
        #            render. Pure black (48;5;16) is too harsh.
        sys.stdout.write(
            "\033[?1049h"      # alt screen
            "\033[?25l"        # hide cursor
            "\033[2J"          # clear
            "\033[0m"          # reset attrs
            "\033[48;5;236"    # dark gray bg
            "\033[H"           # home
        )
        sys.stdout.flush()

    # ChatGPT-style: input box pinned at the BOTTOM, transcript scrolls above.
    # Screen layout (rows numbered top-to-bottom):
    #   row 1..(H-BOX_TOTAL_ROWS):  scroll region (chat) — terminal auto-scrolls
    #                                here when content overflows. The box below
    #                                is OUTSIDE this region so it never gets
    #                                overwritten by auto-scroll.
    #   row H-BOX_TOTAL_ROWS+1:     ╭───╮  top border of input box
    #   row ...                     │⚡ ...│ viewport row 0
    #   row ...                     │     │ viewport row 1
    #   row H:                      ╰───╯  bottom border
    #
    # At the start of each turn we jump to the bottom of the scroll region
    # (row H-BOX_TOTAL_ROWS-FOOTER_ROWS) and print the question + response there.
    # The terminal handles scrolling within the region; the box + footer stay put.
    from multiline_repl import MultiLineEditor, RawMode as ML_RawMode
    # Banner info is rendered into the FOOTER below the box, not above it.
    # That way the model/url/cwd info stays visible no matter how much chat
    # history scrolls off the top.

    try:
        from completion import Completer as _RealCompleter  # noqa: F401
        completer = CompletionAdapter(cwd=cwd)
    except ImportError:
        completer = None

    prompt_text = "⚡ "
    BOX_INNER_ROWS = 2  # compact: just enough for one-line + a bit of overflow
    BOX_TOTAL_ROWS = BOX_INNER_ROWS + 2   # + top + bottom border
    FOOTER_ROWS = 2                        # model/url/cwd line + help hint

    editor = MultiLineEditor(
        prompt=prompt_text,
        completer=completer,
        stream=sys.stdout,
        left_margin=0,
        viewport_rows=BOX_INNER_ROWS,
        viewport_width=80,
        placeholder="Ask Anything",
    )

    # Byte stream for input. We read directly with os.read() so that
    # signals (SIGWINCH, SIGINT) interrupt the read with InterruptedError
    # instead of being silently retried by BufferedReader.
    _stdin_fd = None
    try:
        _stdin_fd = sys.stdin.fileno()
    except (OSError, ValueError):
        pass
    stream = sys.stdin.buffer if hasattr(sys.stdin, "buffer") else sys.stdin

    # TTY/PTY detection (covers mlg-serve case)
    is_tty = False
    try:
        is_tty = (os.isatty(sys.stdin.fileno())
                  or os.isatty(sys.stdout.fileno()))
    except (OSError, ValueError):
        pass

    # Mutable layout state (updated by SIGWINCH handler when terminal is resized)
    layout = {
        "rows": 24,
        "cols": 80,
        "box_top_row": 19,
        "box_inner_first_row": 20,
        "box_bottom_row": 22,
        "footer_row1": 23,
        "footer_row2": 24,
        "scroll_bottom_row": 18,   # last row of the chat scroll region
    }

    def _do_layout(*, clear: bool = True) -> None:
        """Re-query terminal size and redraw the box at the new position.

        Sets a DECSTBM scroll region covering rows 1..(H-BOX_TOTAL_ROWS-FOOTER_ROWS)
        so the chat area auto-scrolls independently of the box + footer.
        Both are OUTSIDE the scroll region and stay pinned at the bottom
        no matter how much chat scrolls.

        `clear=True` wipes the screen first (used on resize when the
        new row count would orphan old content). On initial layout
        (`clear=False`) we leave the chat area empty (no banner).
        """
        rows, cols = _term_size()
        rows = max(rows, BOX_TOTAL_ROWS + FOOTER_ROWS + 5)
        layout["rows"] = rows
        layout["cols"] = cols
        layout["box_top_row"] = rows - BOX_TOTAL_ROWS - FOOTER_ROWS + 1
        layout["box_inner_first_row"] = layout["box_top_row"] + 1
        layout["box_bottom_row"] = layout["box_top_row"] + BOX_TOTAL_ROWS - 1
        layout["footer_row1"] = layout["box_bottom_row"] + 1
        layout["footer_row2"] = layout["footer_row1"] + 1
        layout["scroll_bottom_row"] = layout["box_top_row"] - 1

        if clear:
            # Reset scroll region to full screen, clear, home.
            sys.stdout.write("\033[r\033[2J\033[H")
        else:
            # Just reset scroll region (without clearing) and home.
            sys.stdout.write("\033[r\033[H")
        # Set scroll region: rows 1..scroll_bottom_row (chat area).
        # The box + footer rows are OUTSIDE this region and stay
        # pinned at the bottom even when the chat fills up.
        sys.stdout.write(
            f"\033[1;{layout['scroll_bottom_row']}r"
        )
        # Draw ALL box borders first (top + bottom). Then render the
        # viewport inside the borders. This ordering matters: render_absolute()
        # leaves the cursor at the user's logical typing position INSIDE
        # the viewport, which means drawing the bottom border AFTER it
        # would overwrite viewport content. So we draw the bottom border
        # first, while the cursor is still at row 1 col 1.
        sys.stdout.write("\033[" + str(layout["box_top_row"]) + ";1H")
        sys.stdout.write(color(
            "\u256d" + "\u2500" * 78 + "\u256e",
            C.DIM, enabled=color_on,
        ))
        sys.stdout.write("\033[" + str(layout["box_bottom_row"]) + ";1H")
        sys.stdout.write(color(
            "\u2570" + "\u2500" * 78 + "\u256f",
            C.DIM, enabled=color_on,
        ))
        # Now render the inner viewport. Its cursor will end up inside the
        # box at the typing position.
        editor.render_absolute(layout["box_inner_first_row"])
        # Draw the footer (model/url/cwd + help hint) at the pinned rows
        # BELOW the box. These rows are outside the scroll region so they
        # never get overwritten by chat output.
        _redraw_footer()
        # Move cursor to row 1 (top of scroll region) so the chat area
        # starts fresh.
        sys.stdout.write("\033[1;1H")
        sys.stdout.flush()

    def _redraw_footer() -> None:
        """Redraw the footer (outside the scroll region) with current state.

        Reads `state["total_tokens"]` / `state["last_turn_tokens"]` to
        show a token count when total >= 1000 (silent for short sessions).
        Pulls the latest cwd from `ctx["cwd"]` (set by /cd) so the footer
        reflects post-cd changes without needing a separate callback.
        """
        current_cwd = ctx.get("cwd", cwd)
        # Use the resolved model name when available (set by /model).
        current_model = state.get("model_resolved") or args.model
        row1_text, row1_style = footer_row1(
            current_model, args.url, str(current_cwd),
            tokens_total=state.get("total_tokens", 0),
            tokens_last=state.get("last_turn_tokens", 0),
        )
        draw_footer(
            row1_text=row1_text,
            row1_style=row1_style,
            row2_text=footer_row2(),
            row1=layout["footer_row1"],
            color_on=color_on,
        )

    if is_tty:
        # Save cursor + hide it.
        sys.stdout.write("\033[?25l")
        sys.stdout.flush()

    def _cleanup_terminal() -> None:
        try:
            # Only leave the alt screen if we actually entered it. The
            # `use_alt` closure var is captured at chat-UI entry; the
            # atexit handler runs once at process exit so a clean run
            # of `mlg "question"` (which never entered the alt screen)
            # won't try to leave a buffer we never entered. Without
            # this guard, a stray 1049l on a real terminal flips the
            # visible buffer back to nothing and confuses the user.
            leave_alt = use_alt
            seq = (
                "\033[0m"      # reset attrs (kill dark bg, color, bold)
                "\033[?25h"    # show cursor
                "\033[r"       # full-screen scroll region
            )
            if leave_alt:
                seq += "\033[?1049l"  # leave alt screen
            sys.stdout.write(seq)
            sys.stdout.flush()
        except Exception:
            pass
    atexit.register(_cleanup_terminal)

    # SIGWINCH handler — declared here (not inside the TTY branch) so
    # the non-TTY REPL path can also reference it.
    _sigwinch_pending = [False]
    def _on_sigwinch(signum, frame):
        _sigwinch_pending[0] = True
    try:
        signal.signal(signal.SIGWINCH, _on_sigwinch)
    except (AttributeError, ValueError):
        pass

    if is_tty:
        # Initial layout — don't clear the screen (nothing to wipe on startup).
        _do_layout(clear=False)

    else:
        # Non-TTY fallback — just print the prompt, no box.
        sys.stdout.write(editor.prompt)
        sys.stdout.flush()

    def _redraw_box() -> None:
        """Walk cursor to the box's first inner row and re-render it.
        Leaves the cursor at the logical position inside the box (the
        caller's cursor before this call is NOT restored — wrap calls
        in DECSC/DECRC if you need to restore)."""
        if not is_tty:
            return
        editor.render_absolute(layout["box_inner_first_row"])

    # Local reference so SIGWINCH handler can flip it
    with ML_RawMode():
        while True:
            # Check for SIGWINCH (terminal resize) and relayout.
            if _sigwinch_pending[0]:
                _sigwinch_pending[0] = False
                _do_layout()
                continue
            try:
                if _stdin_fd is not None:
                    # Use select() with a short timeout so we periodically
                    # check the SIGWINCH flag even when no data is arriving.
                    import select as _sel
                    rdy, _, _ = _sel.select([_stdin_fd], [], [], 0.1)
                    if not rdy:
                        # No input ready — loop back and recheck the flag.
                        continue
                    b = os.read(_stdin_fd, 1)
                else:
                    b = stream.read(1)
            except InterruptedError:
                sys.stderr.write("[loop] read interrupted by signal\n")
                sys.stderr.flush()
                # Signal (e.g. SIGWINCH) interrupted the read. Loop back
                # so the flag check at the top can handle it.
                continue
            except (EOFError, KeyboardInterrupt):
                sys.stdout.write("\n")
                _redraw_box()
                # Hand the bye message over to the alt screen, then leave it
                print(color("👋 bye", C.DIM, enabled=color_on))
                _cleanup_terminal()
                if _auto_sync_training_data(home, state["url"], api_key, user_id):
                    state["_auto_sync_done"][0] = True
                break
            if not b:
                print(color("👋 bye", C.DIM, enabled=color_on))
                _cleanup_terminal()
                if _auto_sync_training_data(home, state["url"], api_key, user_id):
                    state["_auto_sync_done"][0] = True
                break

            lines = editor.feed(b)
            submitted = None
            cancelled = False
            for line in lines:
                from multiline_repl import CANCEL
                if line is CANCEL:
                    cancelled = True
                    continue
                if line is None:
                    continue
                submitted = line

            if cancelled:
                editor.clear()
                _redraw_box()
                continue

            if submitted is not None:
                # Jump to the bottom of the scroll region (just above the
                # box) and print the question there. The terminal handles
                # scrolling within the region naturally — the box below is
                # OUTSIDE the region and stays pinned at the bottom.
                if is_tty:
                    # Position cursor at the bottom of the scroll region
                    # (row scroll_bottom_row). Newlines here will scroll
                    # within the region without touching the box.
                    sys.stdout.write(
                        "\033[" + str(layout["scroll_bottom_row"]) + ";1H"
                    )
                    if submitted.strip():
                        # User prompt icon — defaults to 👤 (person, per David 2026-06-28).
                        # Override via MILAGRO_USER_ICON env var.
                        uicon = os.environ.get("MILAGRO_USER_ICON", "👤")
                        # Pad so text aligns with LLM-prompt column.
                        if len(uicon) > 1:
                            prompt_prefix = f"  {uicon} "
                        else:
                            prompt_prefix = f"  {uicon} "
                        sys.stdout.write(color(
                            prompt_prefix + submitted.replace("\n", "\n    "),
                            C.BOLD, C.WHITE, enabled=color_on,
                        ) + "\n")
                    action = handle_repl_line(raw=submitted, cwd=cwd,
                                               state=state, ctx=ctx)
                else:
                    sys.stdout.write("\n")
                    if submitted.strip():
                        uicon = os.environ.get("MILAGRO_USER_ICON", "👤")
                        prompt_prefix = f"  {uicon} "
                        sys.stdout.write(prompt_prefix + submitted + "\n")
                    action = handle_repl_line(raw=submitted, cwd=cwd,
                                               state=state, ctx=ctx)
                if action == "exit":
                    _cleanup_terminal()
                    if _auto_sync_training_data(home, state["url"], api_key, user_id):
                        state["_auto_sync_done"][0] = True
                    return
                # Clear the box + redraw it for the next question.
                editor.clear()
                if is_tty:
                    _redraw_box()
                    # Redraw footer too — tokens may have changed and the
                    # model/url/cwd line could need refreshing (e.g. after
                    # a /model or /cd command).
                    _redraw_footer()
                else:
                    sys.stdout.write(editor.prompt)
                    sys.stdout.flush()
                continue

            # Plain keystroke — redraw the box (cursor moves to logical pos).
            _redraw_box()


if __name__ == "__main__":
    main()