"""Terminal UI for Superpitch."""

from __future__ import annotations

import shutil
import sys
from datetime import datetime
from pathlib import Path

try:
    import termios
    import tty
except ImportError:
    termios = None
    tty = None

from superpitch.app_paths import APP_HOME, APP_NAME, FEEDBACK_FILE, log_sync_error
from superpitch.core import collect_feedback, evaluate_pitch
from superpitch.state import (
    STAGE_MAP,
    STAGE_OPTIONS,
    build_ranked_items,
    format_stage_list,
    history_bucket,
    load_config,
    load_history,
    mark_pitch_opened,
    parse_stage_selection,
    profile_status_line,
    save_config,
    save_history_entry,
    update_history_item,
)
from superpitch.updates import run_upgrade
from superpitch import local as _local

try:
    from superpitch.sync import (
        SyncPoller,
        check_handle,
        claim_handle,
        contribute_decision,
        fetch_pitches,
        poll_cli_auth,
        push_decision,
        score_and_save_cloud_pitches,
        set_thesis,
        start_cli_auth,
    )
    _SYNC_AVAILABLE = True
except ImportError:
    _SYNC_AVAILABLE = False

CURRENT_ONBOARDING_VERSION = 2

# Cached once — never changes during a process lifetime
_IS_TTY = bool(
    sys.stdin.isatty()
    and sys.stdout.isatty()
    and termios is not None
    and tty is not None
)

_DECISION_LABELS = {"approve": "approved", "reject": "rejected", "review": "flagged for review"}
_KEY_TO_DECISION = {
    "a": "approve", "1": "approve",
    "r": "reject",  "2": "reject",
    "v": "review",  "3": "review",
}

# ── Colors ────────────────────────────────────────────────────────────────────
_R        = "\033[0m"
_B        = "\033[1m"
_D        = "\033[2m"
_WHITE    = "\033[97m"
_GREEN    = "\033[92m"
_RED      = "\033[91m"
_YELLOW   = "\033[93m"
_AMBER    = "\033[38;2;226;166;70m"    # #E2A646 foreground
_AMBER_BG = "\033[48;2;226;166;70m"   # #E2A646 background
_FG_DARK  = "\033[38;2;15;15;15m"     # near-black (for text on amber bg)

def _ac(action: str) -> str:
    """ANSI color for an action/decision word."""
    return {"APPROVE": _GREEN, "REJECT": _RED, "REVIEW": _YELLOW}.get(action.upper(), "")

def _sc(score: float) -> str:
    """ANSI color for a numeric score."""
    if score >= 7: return _GREEN
    if score >= 4: return _YELLOW
    return _RED

def _bar(v: float, width: int = 10) -> str:
    """Block-char progress bar  e.g. ████████░░"""
    filled = round(max(0.0, min(10.0, v)) / 10.0 * width)
    return "█" * filled + "░" * (width - filled)

def _rel_time(ts: str) -> str:
    """Convert ISO timestamp to human relative string."""
    if not ts:
        return ""
    try:
        delta = datetime.now() - datetime.fromisoformat(ts)
        if delta.days == 0:
            h = delta.seconds // 3600
            return "just now" if h == 0 else f"{h}h ago"
        return "yesterday" if delta.days == 1 else f"{delta.days}d ago"
    except (ValueError, TypeError):
        return ""

# ── Terminal helpers ──────────────────────────────────────────────────────────

def _tw():
    return max(60, shutil.get_terminal_size((80, 24)).columns - 2)


def _div():
    return "  " + "─" * _tw()


def _clear():
    if _IS_TTY:
        sys.stdout.write("\033[H\033[2J\033[3J")
        sys.stdout.flush()


def _vlen(s: str) -> int:
    """Visible length of a string, stripping ANSI escape codes."""
    n, i = 0, 0
    while i < len(s):
        if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
            i += 2
            while i < len(s) and s[i] != "m":
                i += 1
            i += 1
        else:
            n += 1
            i += 1
    return n


def _wrap(text: str, width: int) -> list[str]:
    """Word-wrap text to width, return list of lines."""
    lines: list[str] = []
    line: list[str] = []
    cur = 0
    for word in text.split():
        need = len(word) + (1 if line else 0)
        if cur + need > width and line:
            lines.append(" ".join(line))
            line, cur = [word], len(word)
        else:
            line.append(word)
            cur += need
    if line:
        lines.append(" ".join(line))
    return lines or [""]


def _render_split(left_lines: list[str], right_lines: list[str], left_w: int) -> None:
    """Print two columns side by side separated by a dim │."""
    n = max(len(left_lines), len(right_lines))
    for i in range(n):
        l = left_lines[i] if i < len(left_lines) else ""
        r = right_lines[i] if i < len(right_lines) else ""
        pad = " " * max(0, left_w - _vlen(l))
        print(f"  {l}{pad}  {_D}│{_R}  {r}")


def _print_header() -> None:
    """Compact single-line brand header."""
    tw = _tw()
    bar = "─" * ((tw - 12) // 2)
    print(f"  {_D}{bar}{_R}  {_AMBER}{_B}SUPERPITCH{_R}  {_D}{bar}{_R}")


def _st(view="top", selected=0, notice="", quit=False):
    """Build a loop-state dict."""
    return {"view": view, "selected": selected, "notice": notice, "quit": quit}


def _wait(msg=""):
    """Print optional message and block until enter."""
    if msg:
        print(f"\n  {msg}")
    input("\n  Press enter to continue...")
    return _st()


def _parse_number(command: str):
    """Return the integer after the first word in a command, or None."""
    try:
        return int(command.split()[1])
    except (IndexError, ValueError):
        return None


# ── Input ─────────────────────────────────────────────────────────────────────

def read_keypress():
    if not _IS_TTY:
        return input().strip().lower()
    fd = sys.stdin.fileno()
    old = termios.tcgetattr(fd)
    try:
        tty.setraw(fd)
        c = sys.stdin.read(1)
        if c == "\x03":
            raise KeyboardInterrupt
        if c in ("\r", "\n"):
            return "ENTER"
        if c == "\x1b":
            c2 = sys.stdin.read(1)
            if c2 == "[":
                c3 = sys.stdin.read(1)
                if c3 == "A":
                    return "UP"
                if c3 == "B":
                    return "DOWN"
            return "ESC"
        return c.lower()
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old)


# ── Brand / static screens ────────────────────────────────────────────────────

def print_result(result):
    print()
    print(_div())
    sub = result["sub"]
    print(f"  {result['final_action'].upper()}  {result['pred_score']:.1f}/10   ·   confidence: {result['confidence']['level']}")
    print(f"  team {sub['team']:.1f}  ·  traction {sub['traction']:.1f}  ·  market {sub['market']:.1f}  ·  risk {sub['risk']:.1f}")
    if result["review_reasons"]:
        print(f"  flags: {', '.join(result['review_reasons'])}")
    if result["signals"]["positive"]:
        print(f"  + {', '.join(result['signals']['positive'])}")
    if result["signals"]["negative"]:
        print(f"  – {', '.join(result['signals']['negative'])}")
    print(_div())


def print_help():
    print()
    print(_div())
    print("""
  KEYS
  ↑↓             move selection
  enter          open pitch
  a / r / v      approve / reject / review selected
  u              add a pitch
  /              command mode
  ?              this help
  q              quit

  COMMANDS
  /open N        open pitch N
  /approve N     mark approved
  /reject N      mark rejected
  /review N      flag for review
  /add           paste or drop a pitch
  /import <file> import a PDF or text file
  /install-local install Ollama + Qwen3-4B for AI score explanations
  /today         today's pitches
  /all           all pitches
  /thesis        update your investor thesis
  /sync          fetch new pitches from cloud
  /signin        sign in with Google
  /signout       sign out
  /subscription  manage subscription
  /upgrade       upgrade via pipx
  /profile       show profile
  /help          this help
  /quit          exit

  FEEDBACK
  x.com/superpitchdev
  github.com/eyoellundberg/superpitch/issues""")
    print(_div())


# ── Onboarding ────────────────────────────────────────────────────────────────

def onboarding_flow(created_files):
    config = load_config()
    if config.get("onboarding_complete") and int(config.get("onboarding_version", 0)) >= CURRENT_ONBOARDING_VERSION:
        return config

    _clear()
    _print_header()
    print(_div())
    print(f"\n  Welcome to {APP_NAME}\n")
    print(f"  {_AMBER}[1]{_R} Local   — free, runs on your machine")
    print(f"  {_AMBER}[2]{_R} Cloud   — get your pitch link at superpitch.dev/yourhandle")
    print()

    while True:
        print("  > ", end="", flush=True)
        choice = read_keypress()
        print(choice)
        if choice in ("1", "2"):
            break

    if choice == "2":
        signin_flow(config)
        config = load_config()

    # Local users set thesis here — cloud users set it during signin_flow
    if not config.get("signed_in"):
        print()
        thesis = input("  Your thesis (optional — shown to founders): ").strip()
        config.update(thesis=thesis)

    config.update(preferred_stages=[], onboarding_complete=True,
                  onboarding_version=CURRENT_ONBOARDING_VERSION)
    if not config.get("signed_in"):
        config.update(mode="local", signed_in=False)
    save_config(config)
    return config


# ── Pitch input helpers ───────────────────────────────────────────────────────

def _read_raw_text():
    print("  Paste pitch text (empty line to finish):")
    lines = []
    while True:
        try:
            line = input()
        except EOFError:
            break
        if not line:
            break
        lines.append(line)
    return "\n".join(lines)


def _normalize_path(raw: str):
    p = raw.strip()
    if not p:
        return None
    if len(p) >= 2 and p[0] == p[-1] and p[0] in {"'", '"'}:
        p = p[1:-1]
    return Path(p.replace("\\ ", " ")).expanduser()


def _pitch_files(folder: Path):
    allowed = {".txt", ".md", ".rtf", ".json", ".jsonl", ".pdf"}
    return sorted(p for p in folder.iterdir()
                  if p.is_file() and p.suffix.lower() in allowed and not p.name.startswith("."))


def _read_file_text(path: Path) -> str:
    if path.suffix.lower() == ".pdf":
        try:
            from pypdf import PdfReader
            reader = PdfReader(str(path))
            return "\n\n".join(page.extract_text() or "" for page in reader.pages)
        except Exception:
            return ""
    return path.read_text(encoding="utf-8", errors="ignore")


def _read_path_input():
    print("  Drop a file or folder path here, then press enter:")
    dropped = _normalize_path(input("  Path: "))
    if dropped is None or not dropped.exists():
        print("  Path not found.")
        return None, []
    if dropped.is_dir():
        files = _pitch_files(dropped)
        if not files:
            print("  No supported files found in that folder.")
            return None, []
        return None, files
    return dropped, []


def _process_pitch(bundle, text, index_vecs, examples, pitch=None, source_label="manual", request_feedback=True):
    result = evaluate_pitch(bundle, text, index_vecs=index_vecs, examples=examples, inp=pitch)
    print_result(result)
    ai = _local.explain(text, result) if _local.is_available() else None
    save_history_entry(
        text, pitch, result, source_label,
        ai_explanation=ai.get("explanation") if ai else None,
        ai_questions=ai.get("questions") if ai else None,
    )
    if request_feedback:
        collect_feedback(text, result["pred_score"], result["final_action"], inp=pitch,
                         confidence=result["confidence"], signals=result["signals"],
                         diagnostics=result["diagnostics"], is_moonshot=result["is_moonshot"])


def process_pitch(bundle, text, index_vecs, examples, source_label="manual"):
    """Score a single pitch from non-interactive context (CLI file arg / stdin)."""
    _process_pitch(bundle, text, index_vecs, examples, source_label=source_label,
                   request_feedback=_IS_TTY)


def add_pitch_flow(bundle, index_vecs, examples):
    while True:
        print()
        print(_div())
        print("  Add pitch\n")
        print("  [1] Paste pitch text")
        print("  [2] Drop a file or folder path")
        print("  [b] Back")
        print(_div())
        print("\n  > ", end="", flush=True)
        choice = read_keypress()
        if choice in {"b", "q"}:
            return
        if choice == "1":
            text = _read_raw_text()
            if text.strip():
                _process_pitch(bundle, text, index_vecs, examples, source_label="pasted text")
                return
        elif choice == "2":
            path, files = _read_path_input()
            if path is None and not files:
                continue
            if path is not None:
                _process_pitch(bundle, _read_file_text(path),
                               index_vecs, examples, source_label=str(path))
                return
            print(f"  Scoring {len(files)} files from {files[0].parent}")
            for f in files:
                print(f"\n  -> {f.name}")
                _process_pitch(bundle, _read_file_text(f),
                               index_vecs, examples, source_label=str(f), request_feedback=False)
            return


# ── Inbox view ────────────────────────────────────────────────────────────────

_DONE_SYM = {"approve": "✓", "reject": "✗", "review": "~"}



def print_inbox(config, selected=0, view="top", notice=""):
    items      = load_history()
    ranked_all = build_ranked_items(items, config)
    tw         = _tw()
    _, term_rows = shutil.get_terminal_size((80, 24))
    today      = datetime.now().date().isoformat()

    queue = [(ri, item, fit) for ri, item, fit in ranked_all if history_bucket(item) != "decided"]
    done  = [(ri, item, fit) for ri, item, fit in ranked_all if history_bucket(item) == "decided"]
    done.sort(key=lambda e: e[1].get("decided_at", "") or e[1].get("timestamp", ""), reverse=True)

    if view == "today":
        queue = [e for e in queue if e[1].get("timestamp", "")[:10] == today]
        done  = [e for e in done  if e[1].get("timestamp", "")[:10] == today]

    done_cap    = None if view == "all" else 5
    done_shown  = done if done_cap is None else done[:done_cap]
    done_hidden = len(done) - len(done_shown)

    displayed = queue + done_shown
    selected  = max(0, min(selected, len(displayed) - 1)) if displayed else 0

    # Move to home without clearing — we overwrite in-place to avoid flicker.
    # \033[J at the end clears any leftover content from a taller previous render.
    if _IS_TTY:
        sys.stdout.write("\033[H")
        sys.stdout.flush()

    # ── column widths ─────────────────────────────────────────────────────────
    # Row: 2 indent + 2 cursor + DATE + 2 + COMP + 2 + STAGE + 2 + SCORE + 2 + STATUS
    DATE_W   = 10   # 2026:03:12
    STAGE_W  = 12
    SCORE_W  = 5
    STATUS_W = 9
    overhead = 2 + 2 + 4 * 2 + STATUS_W   # fixed parts + 4 gaps + status
    COMP_W   = max(16, tw - DATE_W - STAGE_W - SCORE_W - overhead)

    solid = "  " + "─" * (tw - 2)
    dots  = "  " + "·" * (tw - 2)
    # Row content width excluding indent+cursor (for amber bg padding)
    row_inner = DATE_W + 2 + COMP_W + 2 + STAGE_W + 2 + SCORE_W + 2 + STATUS_W

    # ── build output lines ────────────────────────────────────────────────────
    out: list[str] = []

    # Header
    if config.get("signed_in") and config.get("user_email"):
        auth_str = f"{_D}Logged in as {config['user_email']}{_R}"
    else:
        auth_str = f"{_D}Not signed in{_R}"
    last      = config.get("last_synced_at", "")
    right_str = (f"{_D}Last checked: {_rel_time(last)}{_R}" if last else f"{_D}Local{_R}")
    hdr_l     = f"  {_AMBER}{_B}Superpitch{_R}  {auth_str}"
    gap       = tw + 2 - _vlen(hdr_l) - _vlen(right_str)
    out.append(f"{hdr_l}{' ' * max(2, gap)}{right_str}")
    if notice:
        out.append(f"  {_B}{notice}{_R}")
    out.append(solid)

    # Section label
    view_label = {"today": "TODAY", "all": "ALL PITCHES"}.get(view, "Inbox")
    out.append(f"\n  {_AMBER}{view_label}{_R}")
    out.append(dots)

    # Column headers (amber)
    out.append(f"  "
               f"  {_AMBER}{'DATE':<{DATE_W}}{_R}"
               f"  {_AMBER}{'COMPANY':<{COMP_W}}{_R}"
               f"  {_AMBER}{'STAGE':<{STAGE_W}}{_R}"
               f"  {_AMBER}{'SCORE':<{SCORE_W}}{_R}"
               f"  {_AMBER}{'STATUS':>{STATUS_W}}{_R}")
    out.append(dots)

    # Rows
    if not displayed:
        out.append("")
        if config.get("signed_in") and config.get("user_slug"):
            out.append(f"  {_D}Inbox empty.  Share superpitch.dev/{config['user_slug']} with founders.{_R}")
        else:
            out.append(f"  {_D}Inbox empty.  u to paste  ·  /import to load  ·  /signin for your pitch link{_R}")
    else:
        for i, (ri, item, fit) in enumerate(displayed):
            is_sel = i == selected
            dec    = item.get("decision_state") or ""
            is_new = not item.get("opened_at") and not dec

            # cell plain values (for width)
            ts      = item.get("timestamp", "")[:10].replace("-", ":")
            date_v  = ts[:DATE_W].ljust(DATE_W)
            comp_v  = (item.get("one_liner") or "")[:COMP_W].ljust(COMP_W)
            inp     = item.get("input") or {}
            stg_raw = inp.get("stage") or fit.get("detected_stage") or ""
            stage_v = STAGE_MAP.get(stg_raw, stg_raw)[:STAGE_W].ljust(STAGE_W)
            score   = float(item.get("pred_score", 0))
            score_v = f"{score:.1f}".ljust(SCORE_W)

            # status plain value
            if dec:
                sym      = _DONE_SYM.get(dec, "")
                stat_lbl = {"approve": "Approved", "reject": "Rejected", "review": "Flagged"}.get(dec, dec)
                stat_plain = f"{sym} {stat_lbl}"
            elif is_new:
                stat_plain = "New"
            else:
                stat_plain = "Opened"
            stat_v = stat_plain.rjust(STATUS_W)

            out.append("")  # spacing above each row

            if is_sel:
                # Full-width amber background row
                inner = f" {date_v}  {comp_v}  {stage_v}  {score_v}  {stat_v}"
                padded = inner.ljust(row_inner + 1)
                out.append(f"  {_AMBER_BG}{_FG_DARK}{padded}{_R}")
            else:
                # Colored status for normal rows
                if dec:
                    stat_col = f"{_ac(dec)}{stat_v}{_R}"
                elif is_new:
                    stat_col = f"{stat_v}"   # full brightness
                else:
                    stat_col = f"{_D}{stat_v}{_R}"

                if dec:
                    out.append(f"    {_D}{date_v}  {comp_v}  {stage_v}  {score_v}  {_R}{stat_col}")
                elif is_new:
                    out.append(f"    {date_v}  {comp_v}  {_D}{stage_v}{_R}  {_sc(score)}{score_v}{_R}  {stat_col}")
                else:
                    out.append(f"    {_D}{date_v}  {comp_v}  {stage_v}  {_sc(score)}{score_v}{_R}  {stat_col}")

    if done_hidden:
        out.append(f"\n  {_D}+ {done_hidden} more  ·  /all{_R}")

    # ── print content, pin footer to bottom ───────────────────────────────────
    for line in out:
        print(line)

    if _IS_TTY:
        footer_l = f"  {_D}↑↓ move  ·  ↵ open{_R}"
        footer_r = f"{_D}u add  ·  a/r/v decide  ·  / commands  ·  q quit{_R}"
        gap_f    = tw + 2 - _vlen(footer_l) - _vlen(footer_r)
        footer_line = f"{footer_l}{' ' * max(4, gap_f)}{footer_r}"
        # Count actual visual lines (each embedded \n adds an extra line)
        total_content_lines = sum(1 + s.count("\n") for s in out)
        padding = max(0, term_rows - total_content_lines - 2)  # 2 = solid + footer
        print("\n" * padding, end="")
        print(solid)
        # Print footer without trailing newline, then erase everything below
        print(footer_line, end="", flush=True)
        sys.stdout.write("\033[J")
        sys.stdout.flush()
    else:
        print(f"\n  {_D}/help for commands{_R}")

    return displayed, selected


# ── Detail / profile views ────────────────────────────────────────────────────

def pitch_detail_flow(ranked, selected, config=None):
    if not ranked:
        return
    real_index, item, fit = ranked[selected]
    item = mark_pitch_opened(real_index, item)

    while True:
        _clear()
        tw      = _tw()
        left_w  = max(30, min(42, tw // 3))
        right_w = tw - left_w - 6   # 2 indent + 2 pad + │ + 2 pad

        action = item["final_action"].upper()
        score  = item["pred_score"]

        # ── left column: judgment ─────────────────────────────────────────────
        left: list[str] = []

        # score + decision state
        decision_str = ""
        if item.get("decision_state"):
            decision_str = f"  {_ac(item['decision_state'])}{item['decision_state'].upper()}{_R}"
        left.append(f"{_ac(action)}{_B}{action}{_R}  {_sc(score)}{_B}{score:.1f}/10{_R}{decision_str}")

        # title
        title = item.get("one_liner") or "(untitled pitch)"
        left.append(f"{_B}{title[:left_w]}{_R}")
        left.append("")

        # scorecard
        sub = item.get("sub_scores") or {}
        if sub:
            for name, val in [("team",     sub.get("team",     0)),
                               ("traction", sub.get("traction", 0)),
                               ("market",   sub.get("market",   0)),
                               ("risk",     sub.get("risk",     0))]:
                left.append(f"  {name:<9} {_sc(val)}{_bar(val, 8)}{_R}  {val:.1f}")
            left.append("")

        # fit
        fit_parts = []
        if fit.get("label"):
            fit_parts.append(f"fit: {fit['label'].lower()}")
        if fit.get("detected_stage"):
            fit_parts.append(STAGE_MAP.get(fit["detected_stage"], fit["detected_stage"]))
        if fit.get("thesis_hits"):
            fit_parts.append(", ".join(fit["thesis_hits"]))
        if fit_parts:
            left.append(f"{_D}{(' · '.join(fit_parts))[:left_w]}{_R}")
            left.append("")

        # signals
        signals = item.get("signals") or {}
        if item.get("review_reasons"):
            left.append(f"{_D}flags: {', '.join(item['review_reasons'])[:left_w - 7]}{_R}")
        if signals.get("positive"):
            left.append(f"{_GREEN}+  {', '.join(signals['positive'])[:left_w - 4]}{_R}")
        if signals.get("negative"):
            left.append(f"{_RED}–  {', '.join(signals['negative'])[:left_w - 4]}{_R}")
        if item.get("review_reasons") or signals.get("positive") or signals.get("negative"):
            left.append("")

        # contact / source
        src = item.get("source_label", "")
        if src.startswith("web:"):
            contact = src[4:]
            if "|" in contact:
                name_part, email_part = contact.split("|", 1)
                if name_part:
                    left.append(f"{_D}contact{_R}  {name_part[:left_w - 9]}")
                    left.append(f"          {email_part[:left_w - 10]}")
                else:
                    left.append(f"{_D}contact{_R}  {email_part[:left_w - 9]}")
            else:
                left.append(f"{_D}contact{_R}  {contact[:left_w - 9]}")
            left.append("")
        elif src and src not in {"manual", "pasted text"}:
            if src.startswith("/"):
                link = f"\033]8;;file://{src}\033\\{src}\033]8;;\033\\"
            else:
                link = src
            left.append(f"{_D}source{_R}  {link[:left_w - 8]}")
            left.append("")

        # AI explanation
        ai_explanation = item.get("ai_explanation")
        ai_questions   = item.get("ai_questions") or []
        if ai_explanation:
            for ln in _wrap(ai_explanation, left_w - 2):
                left.append(f"{_D}{ln}{_R}")
            left.append("")
        if ai_questions:
            left.append(f"{_D}Before the call:{_R}")
            for q in ai_questions:
                for ln in _wrap(f"· {q}", left_w - 2):
                    left.append(f"  {_D}{ln}{_R}")
            left.append("")
        elif not ai_explanation and not _local.is_available():
            left.append(f"{_D}Tip: /install-local{_R}")
            left.append("")

        # actions
        left.append(f"{_GREEN}{_B}[a]{_R} approve")
        left.append(f"{_RED}{_B}[r]{_R} reject")
        left.append(f"{_YELLOW}{_B}[v]{_R} review")
        left.append(f"[b] back")

        # ── right column: pitch text ──────────────────────────────────────────
        right = _wrap(item.get("text") or "(no text)", right_w)

        # ── render ────────────────────────────────────────────────────────────
        print(_div())
        _render_split(left, right, left_w)
        print(_div())
        print("\n  > ", end="", flush=True)

        choice = read_keypress()
        if choice in {"b", "q", "ESC", ""}:
            return
        decision = _KEY_TO_DECISION.get(choice)
        if decision:
            print(f"\n  Reason (optional, enter to skip): ", end="", flush=True)
            reason = input().strip()
            item["decision_state"] = decision
            item["decided_at"] = datetime.now().isoformat()
            item["decision_reason"] = reason
            update_history_item(real_index, item)
            if config:
                _post_decision_hooks(item, config)
            return


def profile_flow(config):
    _clear()
    _print_header()
    print(_div())
    print(f"  Profile  ·  {profile_status_line(config)}")
    print(f"  Stages:  {format_stage_list(config.get('preferred_stages') or [])}")
    print(f"  Thesis:  {config.get('thesis') or '(none)'}")
    print(f"  Storage: {APP_HOME}")
    if config.get("signed_in"):
        contrib = "yes" if config.get("contribute_enabled") else "no"
        last = config.get("last_synced_at", "")
        last_str = _rel_time(last) if last else "never"
        print(f"  Last sync: {last_str}")
        print(f"  Contribute to base model: {contrib}")
    print(_div())
    input("\n  Press enter to go back...")
    return config


# ── Sign-in flow ──────────────────────────────────────────────────────────────

def signin_flow(config):
    """Open the browser for Google auth and poll until complete. Updates config in place."""
    if not _SYNC_AVAILABLE:
        return _wait("Sync module unavailable.")

    _clear()
    _print_header()
    print(_div())
    print("  Sign in with Google\n")
    print("  Opening your browser...")

    try:
        cli_token = start_cli_auth()
    except Exception as exc:
        return _wait(f"Could not start sign-in: {exc}")

    print("  Waiting for browser", end="", flush=True)

    def _tick(elapsed: int) -> None:
        sys.stdout.write(f"\r  Waiting for browser  {elapsed}s  ")
        sys.stdout.flush()

    result = poll_cli_auth(cli_token, on_tick=_tick)
    print()  # newline after the ticker
    if result is None:
        return _wait("Sign-in timed out. Try /signin again.")

    config.update(
        signed_in=True,
        mode="cloud",
        session_token=result.get("session_token", ""),
        user_email=result.get("email", ""),
        user_slug=result.get("slug", ""),
    )

    print(f"\n  Signed in as {config['user_email']}")

    # ── Handle claiming (first sign-in only) ──────────────────────────────────
    if not config.get("user_slug") and _SYNC_AVAILABLE:
        print()
        print("  Pick your handle — founders will pitch you at superpitch.dev/<handle>")
        print()
        while True:
            raw_slug = input("  Handle: ").strip().lower().replace(" ", "-")
            if not raw_slug:
                continue
            check = check_handle(raw_slug)
            if not check.get("available"):
                reason = check.get("reason", "taken")
                print(f"  {raw_slug} is not available ({reason}). Try another.")
                continue
            name = config["user_email"].split("@")[0]
            claimed = claim_handle(config["session_token"], raw_slug, config["user_email"], name)
            if claimed.get("claimed") or claimed.get("slug"):
                config["user_slug"] = claimed.get("slug", raw_slug)
                print(f"  Claimed. Your pitch link: superpitch.dev/{config['user_slug']}")
                break
            elif claimed.get("error") == "already claimed":
                config["user_slug"] = claimed.get("slug", "")
                print(f"  Already claimed: superpitch.dev/{config['user_slug']}")
                break
            else:
                print(f"  Error: {claimed.get('error', 'try again')}")

    # ── Thesis ────────────────────────────────────────────────────────────────
    if config.get("user_slug") and _SYNC_AVAILABLE:
        print()
        print("  Your thesis is shown to founders before they pitch you.")
        print("  e.g. Pre-seed B2B SaaS. No consumer or crypto.")
        print()
        thesis = input("  Thesis (optional): ").strip()
        if thesis:
            config["thesis"] = thesis
            set_thesis(config["session_token"], thesis)
            print("  Saved.")

    if config.get("user_slug"):
        print(f"\n  Your pitch link: superpitch.dev/{config['user_slug']}")
        print(f"  Share it with founders and your inbox fills automatically.")

    config["contribute_enabled"] = False
    save_config(config)


def signout_flow(config):
    config.update(signed_in=False, mode="local", session_token="",
                  user_email="", user_slug="", last_synced_at="")
    save_config(config)
    return _wait("Signed out.")


def _post_decision_hooks(item, config):
    """After a decision, silently push to cloud and optionally contribute."""
    session_token = config.get("session_token")
    cloud_id = item.get("cloud_pitch_id")
    if not session_token:
        return
    if not _SYNC_AVAILABLE:
        return
    try:
        if cloud_id:
            push_decision(session_token, cloud_id, item["decision_state"])
        if config.get("contribute_enabled"):
            contribute_decision(
                session_token,
                item.get("text", ""),
                item["decision_state"],
                reason=item.get("decision_reason") or "",
                explanation=item.get("ai_explanation") or "",
                questions=item.get("ai_questions") or [],
            )
    except Exception as exc:
        log_sync_error("post_decision", exc)


# ── Actions ───────────────────────────────────────────────────────────────────

def _decide(ranked, display_index, decision, config=None):
    """Apply a decision and return a notice string."""
    if display_index < 1 or display_index > len(ranked):
        return "Pitch not found."
    real_index, item, _fit = ranked[display_index - 1]
    item = mark_pitch_opened(real_index, item)
    item["decision_state"] = decision
    item["decided_at"] = datetime.now().isoformat()
    update_history_item(real_index, item)
    if config:
        _post_decision_hooks(item, config)
    title = (item.get("one_liner") or "pitch")[:48]
    return f"[{display_index}] {_DECISION_LABELS[decision]}  ·  {title}"


# ── Event handlers ────────────────────────────────────────────────────────────

def handle_keypress(key, ranked, selected, config, bundle, index_vecs, examples, view):
    if key in ("UP", "DOWN"):
        n = len(ranked)
        delta = -1 if key == "UP" else 1
        return _st(view=view, selected=max(0, min(n - 1, selected + delta)) if n else 0)
    if key == "ENTER":
        if ranked:
            pitch_detail_flow(ranked, selected, config=config)
        return _st(view=view, selected=selected)
    if key in ("a", "r", "v"):
        decision = _KEY_TO_DECISION[key]
        notice = _decide(ranked, selected + 1, decision, config=config) if ranked else ""
        return _st(view=view, selected=selected, notice=notice)
    if key == "u":
        add_pitch_flow(bundle, index_vecs, examples)
        return _st()
    if key == "?":
        print_help()
        input("\n  Press enter to continue...")
        return _st(view=view, selected=selected)
    if key == "/":
        print(f"\n  {_D}add · import <file> · sync · today · all · signin · upgrade · quit{_R}")
        raw = input("  /").strip()
        cmd = f"/{raw}" if raw and not raw.startswith("/") else raw
        result = handle_command(cmd, ranked, config, bundle, index_vecs, examples)
        result["selected"] = 0 if result.get("view", view) != view else selected
        return result
    if key in ("q", "ESC"):
        return _st(quit=True)
    return _st(view=view, selected=selected)


def _run_sync(config, bundle, index_vecs, examples):
    """Fetch new pitches from the Worker, score them locally, add to history."""
    if not _SYNC_AVAILABLE:
        return _wait("Sync module unavailable.")
    if not config.get("signed_in") or not config.get("session_token"):
        return _wait("Not signed in. Use /signin first.")

    print("\n  Syncing pitches...")
    try:
        since = config.get("last_synced_at") or None
        cloud_pitches = fetch_pitches(config["session_token"], since=since)
        if not cloud_pitches:
            config["last_synced_at"] = datetime.now().isoformat()
            save_config(config)
            return _wait("No new pitches.")
        added = score_and_save_cloud_pitches(cloud_pitches, bundle, index_vecs, examples, config)
        return _wait(f"Synced {added} new pitch{'es' if added != 1 else ''}.")
    except PermissionError as exc:
        if "subscription" in str(exc):
            return _wait("No active subscription. Use /subscription to manage billing.")
        config.update(signed_in=False, session_token="")
        save_config(config)
        return _wait("Session expired. Use /signin to reconnect.")
    except Exception as exc:
        return _wait(f"Sync failed: {exc}")


def thesis_flow(config):
    print()
    current = config.get("thesis") or "(none)"
    print(f"  Current thesis: {current}")
    print()
    if not config.get("signed_in"):
        print("  Sign in first to sync your thesis with superpitch.dev.")
        print("  Your thesis is shown to founders before they pitch you.")
        print()
    print("  New thesis (enter to keep current): ", end="")
    new_thesis = input().strip()
    if not new_thesis:
        return _wait("Unchanged.")
    config["thesis"] = new_thesis
    save_config(config)
    if config.get("signed_in") and _SYNC_AVAILABLE:
        try:
            set_thesis(config["session_token"], new_thesis)
            return _wait("Thesis updated and synced.")
        except Exception:
            pass
    return _wait("Thesis saved locally.")


def handle_command(command, ranked, config, bundle, index_vecs, examples):
    if not command:
        return _st()
    if command in {"/quit", "/q", "q"}:
        return _st(quit=True)
    if command == "/help":
        print_help()
        return _wait()
    if command in {"/add", "/add manually"}:
        add_pitch_flow(bundle, index_vecs, examples)
        return _st()
    if command == "/profile":
        profile_flow(config)
        return _st()
    if command == "/thesis":
        return thesis_flow(config)
    if command == "/signin":
        signin_flow(config)
        return _wait()
    if command == "/signout":
        signout_flow(config)
        return _st()
    if command == "/subscription":
        import webbrowser
        webbrowser.open("https://superpitch.dev/auth")
        return _wait("Opening superpitch.dev in your browser.")
    if command == "/sync":
        return _run_sync(config, bundle, index_vecs, examples)
    if command == "/install-local":
        _local.install_flow()
        return _wait()
    if command.startswith("/import"):
        parts = command.split(None, 1)
        if len(parts) < 2:
            return _wait("Usage: /import path/to/file.pdf")
        path = _normalize_path(parts[1])
        if path is None or not path.exists():
            return _wait("File not found.")
        text = _read_file_text(path)
        if not text.strip():
            return _wait("Could not extract text from that file.")
        _process_pitch(bundle, text, index_vecs, examples, source_label=str(path))
        return _st()
    if command == "/upgrade":
        print("\n  Running pipx upgrade superpitch...")
        success, output = run_upgrade()
        if output:
            print(f"\n{output}")
        msg = "Restart Superpitch to use the new version." if success else "Upgrade failed."
        return _wait(msg)
    if command in {"/today", "/all"}:
        return _st(view=command[1:])
    if command == "/top":
        return _st(view="top")

    # commands with a numeric argument
    for prefix, action in (("/open ", "open"), ("/approve ", "approve"),
                            ("/reject ", "reject"), ("/review ", "review")):
        if command.startswith(prefix):
            n = _parse_number(command)
            if n is None:
                return _wait(f"Use {prefix.strip()} N")
            if action == "open":
                pitch_detail_flow(ranked, n - 1, config=config)
                return _st()
            return _st(notice=_decide(ranked, n, action, config=config))

    return _wait("Unknown command. Type /help")


# ── Main loop ─────────────────────────────────────────────────────────────────

def run_interactive_app(bundle, index_vecs, examples, created_files):
    if _IS_TTY:
        sys.stdout.write("\033[?1049h\033[H\033[2J\033[3J")
        sys.stdout.flush()
    poller       = None
    sync_pending = 0  # counts from background thread via callback

    def _on_synced(count: int) -> None:
        nonlocal sync_pending
        sync_pending += count

    try:
        config = onboarding_flow(created_files)

        def _start_poller():
            nonlocal poller
            if _SYNC_AVAILABLE and poller is None and config.get("signed_in") and config.get("session_token"):
                poller = SyncPoller(config, bundle, index_vecs, examples, on_synced=_on_synced)
                poller.start()

        _start_poller()

        view, selected, notice = "top", 0, ""
        while True:
            if sync_pending and not notice:
                n = sync_pending
                sync_pending = 0
                notice = f"Synced {n} new pitch{'es' if n != 1 else ''}."

            ranked, selected = print_inbox(config, selected=selected, view=view, notice=notice)
            notice = ""
            if _IS_TTY:
                key = read_keypress()
                result = handle_keypress(key, ranked, selected, config, bundle, index_vecs, examples, view)
            else:
                cmd = input("\n  > ").strip().lstrip("/")
                result = handle_command(cmd, ranked, config, bundle, index_vecs, examples)
            view     = result.get("view", "top")
            selected = result.get("selected", 0)
            notice   = result.get("notice", "")
            if result.get("quit"):
                break

            _start_poller()  # no-op if already running; starts if user just signed in
    finally:
        if poller:
            poller.stop()
        if _IS_TTY:
            sys.stdout.write("\033[?25h\033[?1049l")
            sys.stdout.flush()
