"""Cloud serve app — worker + MCP + control API for Zo deployment.

Serves: /api/health, /mcp, /api/worker/status, /api/worker/control, /

The cloud worker is designed to run independently on Zo 24/7.  It defaults
to **running** and persists its pause/resume state to disk so it survives
service restarts.  The desktop app can pause/resume the worker but is never
required — the worker operates autonomously.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
from contextlib import asynccontextmanager, suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Persistent worker state
# ---------------------------------------------------------------------------
_STATE_FILE: Path | None = None  # Set at startup from VASTIAN_DATA_ROOT


def _state_file() -> Path:
    """Return the path to the worker state file."""
    global _STATE_FILE
    if _STATE_FILE is None:
        data_root = os.environ.get("VASTIAN_DATA_ROOT", "/home/workspace/vastian-data")
        _STATE_FILE = Path(data_root) / "state" / "cloud_worker_state.json"
        _STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    return _STATE_FILE


def _load_paused() -> bool:
    """Load pause state from disk.  Defaults to **False** (running)."""
    try:
        data = json.loads(_state_file().read_text(encoding="utf-8"))
        return bool(data.get("paused", False))
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        return False  # Default: running


def _save_paused(paused: bool) -> None:
    """Persist the pause flag to disk so it survives restarts."""
    try:
        _state_file().write_text(
            json.dumps({"paused": paused, "updated": datetime.now(UTC).isoformat()}),
            encoding="utf-8",
        )
    except OSError as e:
        logger.warning("Could not persist worker state: %s", e)


# Shared state for worker control (modified by control API, read by worker loop)
_worker_paused: bool = _load_paused()  # Persisted — defaults to running
_worker_last_poll: datetime | None = None
_worker_task: asyncio.Task | None = None
_worker_orchestrator: Any = None  # Legacy — kept for backward compat during migration

# Multi-user pool (Phase 1)
_worker_pool: Any = None  # WorkerPool instance, set in create_cloud_app()


def _get_version() -> str:
    try:
        import importlib.metadata

        return importlib.metadata.version("vastian-network")
    except Exception:
        return "0.1.0"


CLOUD_CONTROL_HTML = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vastian Cloud Worker — vastianconnect.com</title>
<style>
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;margin:0;padding:0;background:#0d1117;color:#c9d1d9;min-height:100vh}
.top-nav{background:#161b22;border-bottom:1px solid #30363d;padding:12px 20px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.top-nav h1{font-size:1rem;color:#58a6ff;margin:0;white-space:nowrap}
.top-nav a{color:#8b949e;text-decoration:none;font-size:0.82em;padding:4px 10px;border-radius:6px;transition:background 0.15s}
.top-nav a:hover{background:#21262d;color:#c9d1d9}
.top-nav a.active{background:#21262d;color:#58a6ff}
.main{max-width:600px;margin:0 auto;padding:20px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:16px}
.card h2{font-size:0.95em;margin:0 0 12px;color:#c9d1d9}
.status-indicator{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:6px;font-weight:600;font-size:0.88em}
.status-running{background:rgba(63,185,80,0.12);color:#3fb950;border:1px solid rgba(63,185,80,0.3)}
.status-paused{background:rgba(248,81,73,0.12);color:#f85149;border:1px solid rgba(248,81,73,0.3)}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.dot-green{background:#3fb950}.dot-red{background:#f85149}
button{padding:8px 16px;border-radius:6px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;cursor:pointer;font-size:0.85em;transition:background 0.15s}
button:hover{background:#30363d}
button.primary{background:#238636;border-color:#238636;color:#fff}
button.primary:hover{background:#2ea043}
button.danger{background:#da3633;border-color:#da3633;color:#fff}
button.danger:hover{background:#f85149}
.meta{font-size:0.78em;color:#8b949e;margin-top:8px}
.meta span{display:inline-block;margin-right:16px}
</style></head><body>
<nav class="top-nav">
  <h1>&#9889; Vastian Cloud</h1>
  <a href="/" class="active">Worker</a>
  <a href="/mobile">Mobile Chat</a>
</nav>
<div class="main">
  <div class="card">
    <h2>Worker Status</h2>
    <div id="status"><span class="status-indicator">Loading...</span></div>
    <div class="meta" id="meta"></div>
    <div style="margin-top:14px;display:flex;gap:8px">
      <button id="btn-start" class="primary" onclick="control('start')">&#9654; Start</button>
      <button id="btn-stop" class="danger" onclick="control('stop')">&#9724; Stop</button>
    </div>
  </div>
  <div class="card">
    <h2>Inbox Watcher</h2>
    <p style="font-size:0.85em;color:#8b949e">The inbox watcher always runs regardless of worker pause state. Drop <code>.txt</code> files into the inbox directory or use the <a href="/mobile" style="color:#58a6ff">Mobile Chat</a> page to upload.</p>
    <div class="meta" id="inboxMeta">Last poll: --</div>
  </div>
</div>
<script>
async function fetchStatus(){try{const r=await fetch('/api/worker/status');return r.json()}catch(e){return{running:false,error:e.message}}}
async function control(action){await fetch('/api/worker/control',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action})});refresh()}
function render(s){
  const el=document.getElementById('status');
  const meta=document.getElementById('meta');
  const inbox=document.getElementById('inboxMeta');
  if(s.running){
    el.innerHTML='<span class="status-indicator status-running"><span class="dot dot-green"></span> Running</span>';
  }else{
    el.innerHTML='<span class="status-indicator status-paused"><span class="dot dot-red"></span> Paused</span>';
  }
  meta.innerHTML='<span>Inbox: '+s.inbox_watcher+'</span><span>Background: '+s.background_ops+'</span>';
  inbox.innerHTML='Last poll: '+(s.last_poll||'--');
}
async function refresh(){const s=await fetchStatus();render(s)}
refresh();setInterval(refresh,10000);
</script></body></html>
"""


MOBILE_PAGE_HTML = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vastian — Mobile</title>
<style>
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;margin:0;padding:0;background:#0d1117;color:#c9d1d9;min-height:100vh;display:flex;flex-direction:column}
.top-bar{background:#161b22;border-bottom:1px solid #30363d;padding:10px 16px;display:flex;align-items:center;gap:12px}
.top-bar h1{font-size:0.95em;color:#58a6ff;margin:0}
.top-bar a{color:#8b949e;text-decoration:none;font-size:0.78em}
.top-bar a:hover{color:#c9d1d9}
.chat-area{flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:6px}
.msg{max-width:85%;padding:8px 12px;border-radius:10px;font-size:0.88em;line-height:1.4;word-wrap:break-word}
.msg.user{background:#21262d;align-self:flex-end;border-bottom-right-radius:2px}
.msg.agent{background:#161b22;border:1px solid #30363d;align-self:flex-start;border-bottom-left-radius:2px}
.msg .agent-label{font-size:0.72em;color:#58a6ff;margin-bottom:2px;font-weight:600}
.input-area{background:#161b22;border-top:1px solid #30363d;padding:10px 12px;display:flex;gap:8px;align-items:flex-end}
.input-area textarea{flex:1;background:#0d1117;color:#c9d1d9;border:1px solid #30363d;border-radius:8px;padding:8px 12px;font-size:0.88em;resize:none;min-height:40px;max-height:120px;font-family:inherit}
.input-area textarea:focus{outline:none;border-color:#58a6ff}
.send-btn{background:#238636;color:#fff;border:none;border-radius:8px;padding:8px 16px;cursor:pointer;font-size:0.85em;font-weight:600;white-space:nowrap}
.send-btn:hover{background:#2ea043}
.send-btn:disabled{opacity:0.5;cursor:not-allowed}
.upload-section{background:#161b22;border-top:1px solid #30363d;padding:10px 16px;display:flex;align-items:center;gap:8px}
.upload-btn{background:#21262d;color:#c9d1d9;border:1px solid #30363d;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:0.82em}
.upload-btn:hover{background:#30363d}
.upload-status{font-size:0.78em;color:#8b949e}
/* Worker status toggle */
.worker-toggle{padding:8px 16px;background:#161b22;border-bottom:1px solid #30363d;font-size:0.78em;display:flex;align-items:center;gap:8px;cursor:pointer}
.worker-toggle:hover{background:#21262d}
.worker-toggle .wdot{width:6px;height:6px;border-radius:50%;display:inline-block}
.worker-details{display:none;background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;font-size:0.78em}
.worker-details.open{display:flex;gap:8px;align-items:center}
.worker-details button{padding:4px 10px;border-radius:4px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;cursor:pointer;font-size:0.78em}
</style></head><body>
<div class="top-bar">
  <h1>&#9889; Vastian</h1>
  <a href="/">Worker Panel</a>
</div>
<div class="worker-toggle" id="workerToggle" onclick="toggleWorkerDetails()">
  <span class="wdot" id="wDot" style="background:#8b949e"></span>
  <span id="wLabel">Worker: --</span>
</div>
<div class="worker-details" id="workerDetails">
  <button onclick="workerCtl('start')">Start</button>
  <button onclick="workerCtl('stop')">Pause</button>
  <span id="wPoll"></span>
</div>
<div class="chat-area" id="chatArea">
  <div class="msg agent"><div class="agent-label">Charles</div>Welcome! How can I help you?</div>
</div>
<div class="input-area">
  <textarea id="msgInput" placeholder="Message..." rows="1" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMsg()}"></textarea>
  <button class="send-btn" id="sendBtn" onclick="sendMsg()">Send</button>
</div>
<div class="upload-section">
  <label class="upload-btn">&#128206; Upload file <input type="file" accept=".txt,.md,.pdf" style="display:none" onchange="uploadFile(this)"></label>
  <span class="upload-status" id="uploadStatus"></span>
</div>
<script>
var chatArea=document.getElementById('chatArea');
var msgInput=document.getElementById('msgInput');
var sendBtn=document.getElementById('sendBtn');

function addMsg(role,text,agentName){
  var d=document.createElement('div');
  d.className='msg '+role;
  if(role==='agent'&&agentName){d.innerHTML='<div class="agent-label">'+agentName+'</div>'+escHtml(text)}
  else{d.textContent=text}
  chatArea.appendChild(d);
  chatArea.scrollTop=chatArea.scrollHeight;
}
function escHtml(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}

async function sendMsg(){
  var msg=msgInput.value.trim();
  if(!msg)return;
  msgInput.value='';sendBtn.disabled=true;
  addMsg('user',msg);
  try{
    var r=await fetch('/api/mobile/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg})});
    var d=await r.json();
    if(d.ok){addMsg('agent',d.response||'(no response)',d.routed_to||d.agent_id||'Charles')}
    else{addMsg('agent','Error: '+(d.error||'Unknown'),'System')}
  }catch(e){addMsg('agent','Error: '+e.message,'System')}
  sendBtn.disabled=false;msgInput.focus();
}

async function uploadFile(input){
  var file=input.files[0];if(!file)return;
  var status=document.getElementById('uploadStatus');
  status.textContent='Uploading '+file.name+'...';
  var fd=new FormData();fd.append('file',file);
  try{
    var r=await fetch('/api/inbox/upload',{method:'POST',body:fd});
    var d=await r.json();
    status.textContent=d.ok?'Uploaded: '+d.filename:'Error: '+(d.error||'Upload failed');
    addMsg('agent','File "'+file.name+'" has been submitted to the inbox for processing.','System');
  }catch(e){status.textContent='Error: '+e.message}
  input.value='';
}

// Worker status
async function refreshWorker(){
  try{
    var r=await fetch('/api/worker/status');var s=await r.json();
    var dot=document.getElementById('wDot');
    var lbl=document.getElementById('wLabel');
    var poll=document.getElementById('wPoll');
    dot.style.background=s.running?'#3fb950':'#f85149';
    lbl.textContent='Worker: '+(s.running?'Running':'Paused');
    if(poll)poll.textContent='Last poll: '+(s.last_poll||'--');
  }catch(e){}
}
function toggleWorkerDetails(){
  var el=document.getElementById('workerDetails');
  el.classList.toggle('open');
}
async function workerCtl(action){
  await fetch('/api/worker/control',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action})});
  refreshWorker();
}
refreshWorker();setInterval(refreshWorker,15000);

// Auto-resize textarea
msgInput.addEventListener('input',function(){this.style.height='auto';this.style.height=Math.min(this.scrollHeight,120)+'px'});
</script></body></html>
"""


LOGIN_PAGE_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — Vastian</title>
<style>
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;margin:0;padding:0;background:#0d1117;color:#c9d1d9;min-height:100vh;display:flex;align-items:center;justify-content:center}
.login-card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:24px;max-width:400px;width:100%}
.login-card h1{font-size:1.25em;margin:0 0 20px;color:#c9d1d9}
.tabs{display:flex;gap:8px;margin-bottom:20px;border-bottom:1px solid #30363d;padding-bottom:12px}
.tab{padding:8px 16px;background:none;border:none;color:#8b949e;cursor:pointer;font-size:0.9em;border-radius:6px}
.tab:hover{color:#c9d1d9}
.tab.active{background:#21262d;color:#58a6ff}
form{display:flex;flex-direction:column;gap:16px}
form.hidden{display:none}
input,textarea{background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:10px 14px;border-radius:8px;font-size:0.9em;font-family:inherit}
input:focus,textarea:focus{outline:none;border-color:#58a6ff}
textarea{resize:vertical;min-height:80px}
button[type=submit]{background:#238636;color:#fff;border:none;padding:10px 20px;border-radius:8px;font-weight:600;cursor:pointer;font-size:0.9em}
button[type=submit]:hover{background:#2ea043}
button[type=submit]:disabled{opacity:0.5;cursor:not-allowed}
.err{color:#f85149;font-size:0.85em;min-height:1.4em}
.back{display:inline-block;margin-top:16px;color:#8b949e;text-decoration:none;font-size:0.85em}
.back:hover{color:#58a6ff}
</style>
</head>
<body>
<div class="login-card">
<h1>Sign in to Vastian</h1>
<div class="tabs">
<button type="button" class="tab active" data-tab="passphrase">Passphrase</button>
<button type="button" class="tab" data-tab="email">Email</button>
</div>
<form id="passphraseForm" method="post" action="/api/auth/login-passphrase">
<textarea name="passphrase" placeholder="Enter your 12-word recovery phrase (space-separated)" required></textarea>
<div class="err" id="passphraseErr"></div>
<button type="submit" id="passphraseBtn">Sign in</button>
</form>
<form id="emailForm" class="hidden" method="post" action="/api/auth/login">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>
<div class="err" id="emailErr"></div>
<button type="submit" id="emailBtn">Sign in</button>
</form>
<a href="/" class="back">&larr; Back to home</a>
</div>
<script>
var next = new URLSearchParams(window.location.search).get('next') || '/dashboard';
document.querySelectorAll('form').forEach(function(f){ f.action += '?next=' + encodeURIComponent(next); });
document.querySelectorAll('.tab').forEach(function(t){
  t.onclick = function(){
    document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});
    t.classList.add('active');
    var tab = t.dataset.tab;
    document.getElementById('passphraseForm').classList.toggle('hidden', tab !== 'passphrase');
    document.getElementById('emailForm').classList.toggle('hidden', tab !== 'email');
  };
});
document.getElementById('passphraseForm').onsubmit = function(e){
  e.preventDefault();
  var btn = document.getElementById('passphraseBtn');
  var err = document.getElementById('passphraseErr');
  btn.disabled = true; err.textContent = '';
  fetch('/api/auth/login-passphrase', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ passphrase: document.querySelector('[name=passphrase]').value })
  }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
  .then(function(o){
    if (o.ok && o.d.ok) window.location.href = next;
    else { err.textContent = o.d.error || 'Invalid passphrase'; btn.disabled = false; }
  }).catch(function(){ err.textContent = 'Network error'; btn.disabled = false; });
  return false;
};
document.getElementById('emailForm').onsubmit = function(e){
  e.preventDefault();
  var btn = document.getElementById('emailBtn');
  var err = document.getElementById('emailErr');
  btn.disabled = true; err.textContent = '';
  fetch('/api/auth/login', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      email: document.querySelector('#emailForm [name=email]').value,
      password: document.querySelector('#emailForm [name=password]').value
    })
  }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
  .then(function(o){
    if (o.ok && o.d.ok) window.location.href = next;
    else { err.textContent = o.d.error || 'Invalid credentials'; btn.disabled = false; }
  }).catch(function(){ err.textContent = 'Network error'; btn.disabled = false; });
  return false;
};
</script>
</body>
</html>
"""


@asynccontextmanager
async def _cloud_lifespan(app: FastAPI):
    global _worker_task, _worker_pool
    yield
    # Flush all multi-user slots before shutdown
    if _worker_pool is not None:
        try:
            await _worker_pool.flush_all()
        except Exception:
            logger.warning("Worker pool flush failed on shutdown", exc_info=True)
    if _worker_task and not _worker_task.done():
        _worker_task.cancel()
        with suppress(asyncio.CancelledError):
            await _worker_task


def create_cloud_app() -> FastAPI:
    """Create FastAPI app for cloud-serve: worker + MCP + control + multi-user."""
    global _worker_pool

    from fastapi.middleware.cors import CORSMiddleware

    from vastian.server.auth import (
        AuthMiddleware,
        authenticate_by_passphrase,
        authenticate_user,
        bootstrap_owner,
        bootstrap_owner_passphrase,
        create_jwt,
        make_auth_cookie_header,
        make_clear_cookie_header,
        register_user,
        user_count,
    )
    from vastian.server.worker_pool import WorkerPool

    # Initialize the multi-user worker pool
    _worker_pool = WorkerPool()

    # Log bootstrap status
    if user_count() == 0:
        logger.warning(
            "No users registered. Bootstrap the owner via POST /api/auth/bootstrap "
            "or set VASTIAN_OWNER_EMAIL + VASTIAN_OWNER_PASSWORD env vars."
        )
    else:
        logger.info("User registry: %d user(s) registered", user_count())

    app = FastAPI(title="Vastian Cloud", docs_url=None, redoc_url=None, lifespan=_cloud_lifespan)
    app.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_methods=["GET", "POST", "OPTIONS"],
        allow_headers=["Content-Type", "Authorization", "MCP-Protocol-Version"],
    )
    # Auth middleware — localhost and public paths bypass, everything else needs JWT
    app.add_middleware(AuthMiddleware)

    @app.get("/api/health")
    def _api_health() -> dict:
        return {"status": "ready", "version": _get_version()}

    # ── Auth endpoints ────────────────────────────────────────────

    @app.post("/api/auth/register")
    async def _api_auth_register(request: Request) -> dict:
        """Register a new user and return a JWT."""
        try:
            body = await request.json()
        except Exception:
            return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400)
        email = (body.get("email") or "").strip()
        password = body.get("password", "")
        name = (body.get("name") or "").strip()
        byos_provider = (body.get("byos_provider") or "").strip()
        if not email or not password:
            return JSONResponse(
                {"ok": False, "error": "email and password are required"},
                status_code=400,
            )
        user_id, err = register_user(email, password, name=name, byos_provider=byos_provider)
        if user_id is None:
            return JSONResponse({"ok": False, "error": err}, status_code=409)
        token = create_jwt(user_id, extra={"email": email, "name": name})
        return {"ok": True, "user_id": user_id, "token": token}

    @app.post("/api/auth/login")
    async def _api_auth_login(request: Request) -> dict:
        """Authenticate and return a JWT."""
        try:
            body = await request.json()
        except Exception:
            return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400)
        email = (body.get("email") or "").strip()
        password = body.get("password", "")
        if not email or not password:
            return JSONResponse(
                {"ok": False, "error": "email and password are required"},
                status_code=400,
            )
        user_id, user = authenticate_user(email, password)
        if user_id is None:
            return JSONResponse({"ok": False, "error": "Invalid credentials"}, status_code=401)
        token = create_jwt(
            user_id,
            extra={"email": user.get("email", ""), "name": user.get("name", "")},
        )
        response = JSONResponse({"ok": True, "user_id": user_id, "token": token})
        response.headers["Set-Cookie"] = make_auth_cookie_header(token, request)
        return response

    @app.post("/api/auth/bootstrap")
    async def _api_auth_bootstrap(request: Request) -> dict:
        """Bootstrap the system by creating the first owner user."""
        try:
            body = await request.json()
        except Exception:
            return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400)
        email = (body.get("email") or "").strip()
        password = body.get("password", "")
        name = (body.get("name") or "").strip()
        if not email or not password:
            return JSONResponse(
                {"ok": False, "error": "email and password are required"},
                status_code=400,
            )
        # Optionally migrate existing data from old default data root
        migrate_from = body.get("migrate_from")
        migrate_path = Path(migrate_from) if migrate_from else None
        user_id, err = bootstrap_owner(
            email,
            password,
            name=name,
            migrate_from=migrate_path,
        )
        if user_id is None:
            return JSONResponse({"ok": False, "error": err}, status_code=409)
        token = create_jwt(user_id, extra={"email": email, "name": name})
        response = JSONResponse(
            {"ok": True, "user_id": user_id, "token": token, "message": "Owner bootstrapped"}
        )
        response.headers["Set-Cookie"] = make_auth_cookie_header(token, request)
        return response

    @app.get("/login", response_class=HTMLResponse)
    def _login_page() -> str:
        return LOGIN_PAGE_HTML

    @app.get("/api/auth/logout")
    @app.post("/api/auth/logout")
    async def _api_auth_logout(request: Request) -> RedirectResponse:
        """Clear auth cookie and redirect to login."""
        response = RedirectResponse(url="/login", status_code=302)
        response.headers["Set-Cookie"] = make_clear_cookie_header()
        return response

    @app.post("/api/auth/login-passphrase")
    async def _api_auth_login_passphrase(request: Request) -> dict:
        """Authenticate by 12-word passphrase and return a JWT."""
        try:
            body = await request.json()
        except Exception:
            return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400)
        phrase = (body.get("passphrase") or "").strip()
        if not phrase:
            return JSONResponse(
                {"ok": False, "error": "passphrase is required"},
                status_code=400,
            )
        user_id, user = authenticate_by_passphrase(phrase)
        if user_id is None:
            return JSONResponse(
                {"ok": False, "error": "Invalid passphrase or not registered"},
                status_code=401,
            )
        token = create_jwt(
            user_id,
            extra={"name": user.get("name", "")},
        )
        response = JSONResponse({"ok": True, "user_id": user_id, "token": token})
        response.headers["Set-Cookie"] = make_auth_cookie_header(token, request)
        return response

    @app.post("/api/auth/bootstrap-passphrase")
    async def _api_auth_bootstrap_passphrase(request: Request) -> dict:
        """Bootstrap the system by creating the first owner from passphrase."""
        try:
            body = await request.json()
        except Exception:
            return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400)
        phrase = (body.get("passphrase") or "").strip()
        name = (body.get("name") or "").strip()
        if not phrase:
            return JSONResponse(
                {"ok": False, "error": "passphrase is required"},
                status_code=400,
            )
        user_id, err = bootstrap_owner_passphrase(phrase, name=name or "Owner")
        if user_id is None:
            return JSONResponse({"ok": False, "error": err}, status_code=409)
        token = create_jwt(user_id, extra={"name": name or "Owner"})
        response = JSONResponse(
            {"ok": True, "user_id": user_id, "token": token, "message": "Owner bootstrapped"}
        )
        response.headers["Set-Cookie"] = make_auth_cookie_header(token, request)
        return response

    @app.get("/api/worker/status")
    def _api_worker_status() -> dict:
        global _worker_paused, _worker_last_poll
        status: dict[str, Any] = {
            "inbox_watcher": "always_active",
            "background_ops": "running" if not _worker_paused else "paused",
            "running": not _worker_paused,
            "last_poll": _worker_last_poll.isoformat() if _worker_last_poll else None,
            "persistent": True,
        }
        if _worker_pool is not None:
            status["pool"] = _worker_pool.status()
        return status

    @app.post("/api/worker/control")
    async def _api_worker_control(request: Request) -> dict:
        global _worker_paused
        try:
            body = await request.json()
        except Exception:
            return JSONResponse({"error": "Invalid JSON"}, status_code=400)
        action = (body.get("action") or "").strip().lower()
        if action == "start":
            _worker_paused = False
            _save_paused(False)
            return {"status": "started"}
        if action == "stop":
            _worker_paused = True
            _save_paused(True)
            return {"status": "stopped"}
        return JSONResponse({"error": f"Unknown action: {action}"}, status_code=400)

    @app.get("/", response_class=HTMLResponse)
    def _root() -> str:
        return CLOUD_CONTROL_HTML

    @app.get("/mobile", response_class=HTMLResponse)
    def _mobile_page() -> str:
        return MOBILE_PAGE_HTML

    @app.post("/api/inbox/upload")
    async def _api_inbox_upload(request: Request) -> dict:
        """Accept file upload and write to user's inbox directory.

        Multi-user: resolves inbox path from the authenticated user's data root
        via WorkerPool, falling back to the global VASTIAN_DATA_ROOT for the
        default user.
        """
        try:
            from starlette.datastructures import UploadFile as _UF  # noqa: F401, N814

            form = await request.form()
            upload = form.get("file")
            if upload is None:
                return JSONResponse({"ok": False, "error": "No file provided"}, status_code=400)

            # Resolve per-user inbox directory
            user_id = getattr(request.state, "user_id", "unregistered")
            if _worker_pool is not None:
                slot = _worker_pool.get_slot(user_id)
                if slot and slot.data_root:
                    inbox_dir = slot.data_root / "saved_prompts" / "inbox"
                else:
                    # User not hydrated yet — resolve from workspace dir or data root
                    from vastian.server.worker_pool import ZO_WORKSPACES_DIR

                    user_workspace = ZO_WORKSPACES_DIR / user_id / "saved_prompts" / "inbox"
                    if user_workspace.parent.parent.exists():
                        inbox_dir = user_workspace
                    else:
                        from vastian.storage.data_root import get_data_root

                        inbox_dir = get_data_root() / "saved_prompts" / "inbox"
            else:
                from vastian.storage.data_root import get_data_root

                inbox_dir = get_data_root() / "saved_prompts" / "inbox"
            inbox_dir.mkdir(parents=True, exist_ok=True)

            ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
            safe_name = "".join(
                c if c.isalnum() or c in ".-_" else "_" for c in upload.filename or "upload.txt"
            )
            dest_name = f"upload-{ts}-{safe_name}"
            content = await upload.read()
            (inbox_dir / dest_name).write_bytes(content)
            logger.info("Inbox upload: %s (%d bytes) for user %s", dest_name, len(content), user_id)
            return {"ok": True, "filename": dest_name, "size": len(content)}
        except Exception as e:
            logger.exception("Inbox upload error")
            return JSONResponse({"ok": False, "error": str(e)}, status_code=500)

    @app.post("/api/email/capture")
    async def _api_email_capture(request: Request) -> dict:
        """Capture email from landing page and append to file on Zo."""
        try:
            body = await request.json()
        except Exception:
            return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400)
        email = (body.get("email") or "").strip()
        name = (body.get("name") or "").strip()
        source = (body.get("source") or "landing").strip()
        if not email or "@" not in email:
            return JSONResponse({"ok": False, "error": "Valid email required"}, status_code=400)
        import csv
        from datetime import datetime

        leads_path = Path(os.environ.get("VASTIAN_DATA_ROOT", ".")) / "leads.csv"
        row = {
            "email": email,
            "name": name,
            "source": source,
            "timestamp": datetime.now(UTC).isoformat(),
        }
        write_header = not leads_path.exists()
        with open(leads_path, "a", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=["email", "name", "source", "timestamp"])
            if write_header:
                writer.writeheader()
            writer.writerow(row)
        logger.info("Email captured: %s (%s)", email, source)
        return {"ok": True}

    @app.post("/api/mobile/chat")
    async def _api_mobile_chat(request: Request) -> dict:
        """Lightweight chat for mobile — routes through Charles.

        Multi-user: uses request.state.user_id from AuthMiddleware to get
        the correct per-user orchestrator from the WorkerPool.
        """
        try:
            body = await request.json()
        except Exception:
            return JSONResponse({"ok": False, "error": "Invalid JSON"}, status_code=400)
        message = (body.get("message") or "").strip()
        if not message:
            return JSONResponse({"ok": False, "error": "message is required"}, status_code=400)

        # Resolve orchestrator from the worker pool
        user_id = getattr(request.state, "user_id", "unregistered")
        user_data = getattr(request.state, "user", None) or {}
        try:
            if _worker_pool is not None:
                orch = await _worker_pool.get_orchestrator(
                    user_id,
                    user_name=user_data.get("name", "User") if user_data else "User",
                    user_tier=user_data.get("tier", "open") if user_data else "open",
                    storage_mode=user_data.get("storage_mode", "zo") if user_data else "zo",
                )
            elif _worker_orchestrator is not None:
                orch = _worker_orchestrator
            else:
                return {
                    "ok": False,
                    "error": "Worker not initialized yet. Please wait for startup.",
                }
        except Exception as e:
            logger.exception("Failed to get orchestrator for user %s", user_id)
            return {"ok": False, "error": f"Worker init failed: {e}"}

        try:
            # Route through Charles with intent routing
            routed_to = None
            if orch.router:
                routed_to = await orch.router.route(message)
                if routed_to and routed_to != "charles":
                    response = await orch.handle_user_message_direct(
                        content=message, agent_id=routed_to
                    )
                else:
                    routed_to = None
                    response = await orch.handle_user_message_direct(
                        content=message, agent_id="charles"
                    )
            else:
                response = await orch.handle_user_message_direct(
                    content=message, agent_id="charles"
                )
            result: dict = {"ok": True, "response": response or "", "agent_id": "charles"}
            if routed_to:
                result["routed_to"] = routed_to
            return result
        except Exception as e:
            logger.exception("Mobile chat error")
            return {"ok": False, "error": str(e)}

    # MCP endpoint
    try:
        from vastian.mcp.server import _handle_json_rpc, _validate_auth

        @app.post("/mcp")
        async def _mcp_post(request: Request):
            auth = request.headers.get("Authorization")
            if not _validate_auth(auth):
                return JSONResponse({"error": "Unauthorized"}, status_code=401)
            try:
                body = await request.json()
            except Exception as e:
                return JSONResponse({"error": str(e)}, status_code=400)
            response = _handle_json_rpc(body)
            if response is None:
                return JSONResponse({}, status_code=202)
            return JSONResponse(response)

        @app.options("/mcp")
        async def _mcp_options():
            from fastapi.responses import Response

            return Response(
                status_code=204,
                headers={
                    "Access-Control-Allow-Origin": "*",
                    "Access-Control-Allow-Methods": "POST, OPTIONS",
                    "Access-Control-Allow-Headers": "Content-Type, Authorization, MCP-Protocol-Version",
                },
            )
    except ImportError:
        pass

    # ── Mount full app for app.vastianconnect.com ──────────────────────
    # Both engine and app subdomains resolve to this same process.
    # We create the full main app (with all API + UI routes) and mount
    # it so /dashboard, /api/chat, /api/feature-gates etc. all work.
    try:
        from vastian.server.app import create_app as _create_main_app

        project_root = Path(os.environ.get("VASTIAN_DATA_ROOT", "."))
        user_tier = os.environ.get("VASTIAN_USER_TIER", "admin")

        state_dir = project_root / "state"
        state_dir.mkdir(parents=True, exist_ok=True)
        _main_services: dict[str, Any] = {
            "project_root": project_root,
            "user_tier": user_tier,
        }
        # Create service instances the main app needs
        try:
            from vastian.tasks.feed import NotificationFeed

            db_path = state_dir / "tasks.db"
            _main_services["feed"] = NotificationFeed(
                db_path=db_path,
                state_dir=state_dir,
                user_tier=user_tier,
            )
        except Exception as exc:
            logger.warning("Feed service init failed: %s", exc)
        try:
            from vastian.tasks.roster import RosterPage

            _main_services["roster"] = RosterPage(
                state_dir=state_dir,
                user_tier=user_tier,
            )
        except Exception as exc:
            logger.warning("Roster service init failed: %s", exc)
        try:
            from vastian.tasks.persona_chart import PersonaChartPage

            _main_services["chart"] = PersonaChartPage()
        except Exception as exc:
            logger.warning("Persona chart init failed: %s", exc)
        try:
            from vastian.tasks.settings_page import SettingsPage

            _main_services["settings"] = SettingsPage(user_tier=user_tier)
        except Exception as exc:
            logger.warning("Settings service init failed: %s", exc)

        # Create and mount the full main app at /v — all its routes
        # become available under /v/ (e.g. /v/api/chat, /v/api/feature-gates)
        _full_app = _create_main_app(services=_main_services)

        # Instead of sub-mounting (which changes URL paths), directly add
        # the unified shell at common UI paths so the cloud app serves them.
        @app.get("/dashboard", response_class=HTMLResponse)
        @app.get("/inbox", response_class=HTMLResponse)
        @app.get("/chat", response_class=HTMLResponse)
        @app.get("/metrics", response_class=HTMLResponse)
        @app.get("/scenarios", response_class=HTMLResponse)
        @app.get("/sessions", response_class=HTMLResponse)
        @app.get("/personas", response_class=HTMLResponse)
        @app.get("/boards", response_class=HTMLResponse)
        @app.get("/plans", response_class=HTMLResponse)
        def _cloud_shell() -> str:
            from vastian.server.unified_shell import render_shell

            return render_shell(user_tier=user_tier, project_root=project_root)

        # Mount the full app so ALL /api/* routes are also available
        # Mount must come AFTER explicit routes (FastAPI checks explicit first)
        app.mount("/", _full_app)

        logger.info("Cloud app: full UI + API routes mounted")
    except Exception as exc:
        logger.warning("Could not mount full app in cloud mode: %s", exc)

    return app


async def _cloud_worker_loop(
    inbox_path: str,
    outbox_path: str,
    interval: int,
) -> None:
    """Zo-inbox worker loop.  Runs until cancelled.

    The **inbox watcher** always runs — it polls ``inbox_path`` for new ``.txt``
    files every ``interval`` seconds regardless of the pause state.  The
    ``_worker_paused`` flag is reserved for future heavier background operations
    (knowledge consolidation, proactive suggestions, etc.) that the user may
    want to toggle on/off from the desktop app.
    """
    global _worker_paused, _worker_last_poll, _worker_orchestrator
    from pathlib import Path

    inbox_dir = Path(inbox_path)
    outbox_dir = Path(outbox_path)
    inbox_dir.mkdir(parents=True, exist_ok=True)
    outbox_dir.mkdir(parents=True, exist_ok=True)

    # Lazy init orchestrator (heavy — session_only skips background loops)
    from vastian.config import get_settings
    from vastian.orchestrator import Orchestrator

    settings = get_settings()
    orch = Orchestrator(settings)
    try:
        await orch.initialize(session_only=True)
    except Exception as e:
        logger.error("Cloud worker init failed: %s", e)
        return
    _worker_orchestrator = orch

    processed: set[str] = set()
    try:
        while True:
            # ── Inbox watcher — ALWAYS active ──────────────────────────
            _worker_last_poll = datetime.now(UTC)
            for f in inbox_dir.glob("*.txt"):
                if f.name in processed:
                    continue
                try:
                    content = f.read_text(encoding="utf-8").strip()
                except Exception:
                    continue
                if len(content) < 10:
                    continue
                processed.add(f.name)
                logger.info("Inbox watcher: processing %s", f.name)
                try:
                    import tempfile

                    from vastian.storage.data_root import get_data_root
                    from vastian.tasks.inbox import InboxWatcher

                    watcher = InboxWatcher(orch, get_data_root(settings.agents_dir.parent))
                    tmp_intake = Path(tempfile.mkdtemp()) / "intake"
                    tmp_complete = Path(tempfile.mkdtemp()) / "complete"
                    tmp_intake.mkdir(parents=True, exist_ok=True)
                    tmp_complete.mkdir(parents=True, exist_ok=True)
                    intake_file = tmp_intake / f.name
                    intake_file.write_text(content, encoding="utf-8")
                    watcher.intake_dir = tmp_intake
                    watcher.complete_dir = tmp_complete
                    await watcher._execute_inbox_task("zo-inbox", f.name, content, intake_file)
                    ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
                    out_name = f"processed-{ts}-{f.name}"
                    (outbox_dir / out_name).write_text(
                        f"Processed: {f.name}\n\nContent length: {len(content)} chars.",
                        encoding="utf-8",
                    )
                    with suppress(Exception):
                        f.unlink()
                except Exception as e:
                    logger.exception("Error processing %s: %s", f.name, e)
                    processed.discard(f.name)

            # ── Background operations — only when worker is un-paused ─
            if not _worker_paused:
                pass  # Future: knowledge consolidation, proactive suggestions, etc.

            await asyncio.sleep(interval)
    finally:
        if orch.task_queue:
            orch.task_queue.close()
