#!/usr/bin/env bash
# robot-install.sh — non-interactive FrankenClaw installer driven by a JSON manifest.
#
# Usage:
#   ./robot-install.sh [path/to/manifest.json]
#   default manifest: ./robot.install
#
# Designed for LLM agents and CI: zero prompts, all human-readable progress on
# stderr, a single JSON object on stdout for the caller to parse.
#
# Stdout shape (always valid JSON):
#   {
#     "ok": true|false,
#     "steps": {
#       "deps":       {"ok": true, "python": "3.12"},
#       "venv":       {"ok": true, "path": "..."},
#       "pip":        {"ok": true},
#       "config":     {"ok": true, "config_path": "...", "keys_path": "..."},
#       "keys":       {"ok": true, "providers_written": ["openrouter"], "providers_missing": ["firecrawl"]},
#       "smoke_test": {"ok": true, "tools_loaded": [...], "tools_failed": {"notebooklm": "missing dep: notebooklm-py"}}
#     },
#     "mcp_snippet": {"command": "...", "args": [...]},   // helper for the caller
#     "error": "<reason>"
#   }
#
# Exit codes:
#   0 — success (ok:true). All requested tools loaded.
#   1 — failure (ok:false). Critical step blew up.
#
# A tool that fails to import is NOT a failure — FrankenClaw is designed to
# come up with whatever tools have their deps satisfied. The smoke_test
# step reports tools_failed so the caller knows what to install if they
# want the full set.
#
# Env overrides (for testing / sandboxed installs):
#   FRANKENCLAW_INSTALL_VENV_DIR    default <repo>/.venv
#   FRANKENCLAW_INSTALL_DRY_RUN     "1" to skip pip + write attempts

set -uo pipefail

REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MANIFEST="${1:-${REPO_DIR}/robot.install}"
VENV_DIR="${FRANKENCLAW_INSTALL_VENV_DIR:-${REPO_DIR}/.venv}"
DRY_RUN="${FRANKENCLAW_INSTALL_DRY_RUN:-0}"

log() { printf '[frankenclaw] %s\n' "$*" >&2; }

STEPS='{}'
MCP_SNIPPET='{}'

set_step() {
  local key="$1" value="$2"
  STEPS=$(python3 - "$STEPS" "$key" "$value" <<'PY'
import json, sys
steps = json.loads(sys.argv[1])
steps[sys.argv[2]] = json.loads(sys.argv[3])
print(json.dumps(steps))
PY
)
}

emit() {
  local ok="$1" error="${2:-}"
  python3 - "$ok" "$error" "$STEPS" "$MCP_SNIPPET" <<'PY'
import json, sys
ok = sys.argv[1] == "true"
err = sys.argv[2]
steps = json.loads(sys.argv[3])
snippet = json.loads(sys.argv[4])
out = {"ok": ok, "steps": steps}
if snippet:
    out["mcp_snippet"] = snippet
if not ok and err:
    out["error"] = err
print(json.dumps(out, indent=2))
PY
  [ "$ok" = "true" ] && exit 0 || exit 1
}

if ! command -v python3 >/dev/null 2>&1; then
  printf '{"ok": false, "error": "python3 not found", "steps": {}}\n'
  exit 1
fi

# ─── parse manifest ───────────────────────────────────────────────────

PARSED=$(python3 - "$MANIFEST" <<'PY'
import json, sys, re, os
from pathlib import Path

path = Path(sys.argv[1])
if not path.exists():
    print(f"__ERROR__=manifest not found: {path}")
    sys.exit(0)

try:
    raw = path.read_text()
    cleaned = "\n".join(
        re.sub(r"^\s*//.*$", "", line) for line in raw.splitlines()
    )
    data = json.loads(cleaned)
except json.JSONDecodeError as e:
    print(f"__ERROR__=invalid JSON in {path}: {e}")
    sys.exit(0)

def expand(p):
    return os.path.expanduser(str(p))

def shquote(s):
    return "'" + str(s).replace("'", "'\\''") + "'"

cfg = data.get("config") or {}
keys_path = expand(data.get("keys_path", "~/.frankenclaw/keys.json"))
print(f"KEYS_PATH={shquote(keys_path)}")
print(f"FC_CONFIG_JSON={shquote(json.dumps(cfg))}")
print(f"PROVIDER_KEYS_JSON={shquote(json.dumps(data.get('provider_keys') or {}))}")

st = data.get("smoke_test") or {}
print(f"SMOKE_ENABLED={'1' if st.get('enabled', True) else '0'}")
tools = st.get("tools") or ["all"]
print(f"SMOKE_TOOLS={shquote(json.dumps(tools))}")
PY
)

if echo "$PARSED" | grep -q '^__ERROR__='; then
  err=$(echo "$PARSED" | sed -n 's/^__ERROR__=//p')
  set_step manifest '{"ok": false}'
  emit false "$err"
fi

eval "$PARSED"
log "manifest parsed: $MANIFEST"

# ─── step 1/5: dependency check ───────────────────────────────────────

log "step 1/5 — dependencies"

missing=""
for c in python3; do
  command -v "$c" >/dev/null 2>&1 || missing="$missing $c"
done

PY_VER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PY_OK=$(python3 -c 'import sys; print(1 if sys.version_info >= (3,10) else 0)')
[ "$PY_OK" = "1" ] || missing="$missing python>=3.10(have_$PY_VER)"

if [ -n "${missing// }" ]; then
  set_step deps "{\"ok\": false, \"missing\":\"${missing# }\"}"
  emit false "missing dependencies:${missing}"
fi

set_step deps "{\"ok\": true, \"python\": \"$PY_VER\"}"

# ─── step 2/5: venv + pip install ─────────────────────────────────────

log "step 2/5 — venv at $VENV_DIR"

if [ "$DRY_RUN" = "1" ]; then
  set_step venv "{\"ok\": true, \"path\": \"$VENV_DIR\", \"dry_run\": true}"
  set_step pip '{"ok": true, "dry_run": true}'
  PY_BIN=$(command -v python3)
else
  if [ ! -d "$VENV_DIR" ]; then
    if ! python3 -m venv "$VENV_DIR" >&2; then
      set_step venv "{\"ok\": false, \"path\": \"$VENV_DIR\"}"
      emit false "failed to create venv at $VENV_DIR"
    fi
  fi
  set_step venv "{\"ok\": true, \"path\": \"$VENV_DIR\"}"

  PIP_BIN="$VENV_DIR/bin/pip"
  PY_BIN="$VENV_DIR/bin/python"
  if ! "$PIP_BIN" install --quiet --upgrade pip >&2; then
    set_step pip '{"ok": false, "error": "pip upgrade failed"}'
    emit false "pip upgrade failed"
  fi
  if ! "$PIP_BIN" install --quiet -r "$REPO_DIR/requirements.txt" >&2; then
    set_step pip '{"ok": false, "error": "pip install requirements failed"}'
    emit false "pip install failed"
  fi
  set_step pip '{"ok": true}'
fi

# ─── step 3/5: config file ────────────────────────────────────────────

log "step 3/5 — config"

CONFIG_DIR="$HOME/.frankenclaw"
CONFIG_FILE="$CONFIG_DIR/config.json"
mkdir -p "$CONFIG_DIR"
mkdir -p "$(dirname "$KEYS_PATH")"

python3 - "$CONFIG_FILE" "$FC_CONFIG_JSON" <<'PY'
import json, sys
from pathlib import Path
cfg_path = Path(sys.argv[1])
new = json.loads(sys.argv[2])

if cfg_path.exists():
    try:
        existing = json.loads(cfg_path.read_text())
    except Exception:
        existing = {}
else:
    existing = {}

# Merge — caller-supplied keys override; existing keys preserved when
# the manifest didn't speak to them. This makes re-runs idempotent.
merged = {**existing, **new}
cfg_path.write_text(json.dumps(merged, indent=2))
PY

set_step config "{\"ok\": true, \"config_path\": \"$CONFIG_FILE\", \"keys_path\": \"$KEYS_PATH\"}"

# ─── step 4/5: keys file ──────────────────────────────────────────────

log "step 4/5 — keys at $KEYS_PATH"

KEYS_RESULT=$(python3 - "$KEYS_PATH" "$PROVIDER_KEYS_JSON" <<'PY'
import json, os, sys
from pathlib import Path

path = Path(sys.argv[1])
mapping = json.loads(sys.argv[2])  # {provider: env_var_name}

if path.exists():
    try:
        existing = json.loads(path.read_text())
    except Exception:
        existing = {}
else:
    existing = {}

written = []
missing = []
for provider, env_var in mapping.items():
    if not env_var:
        continue
    val = os.environ.get(env_var, "")
    if val:
        existing[provider] = val
        written.append(provider)
    elif provider not in existing:
        # Create an empty placeholder so the file shape is obvious.
        existing[provider] = ""
        missing.append(provider)

path.write_text(json.dumps(existing, indent=2))
os.chmod(path, 0o600)
print(json.dumps({"written": written, "missing": missing}))
PY
)

written=$(echo "$KEYS_RESULT" | python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin)["written"]))')
missing=$(echo "$KEYS_RESULT" | python3 -c 'import sys,json;print(json.dumps(json.load(sys.stdin)["missing"]))')
set_step keys "{\"ok\": true, \"keys_path\": \"$KEYS_PATH\", \"providers_written\": $written, \"providers_missing\": $missing}"

# ─── step 5/5: smoke test — import each tool module ──────────────────

if [ "$SMOKE_ENABLED" != "1" ] || [ "$DRY_RUN" = "1" ]; then
  log "step 5/5 — smoke test (skipped)"
  set_step smoke_test '{"ok": true, "skipped": true}'
  MCP_SNIPPET="$(python3 - "$PY_BIN" "$REPO_DIR" <<'PY'
import json, sys
print(json.dumps({"command": sys.argv[1], "args": [sys.argv[2] + "/server.py"]}))
PY
)"
  emit true
fi

log "step 5/5 — smoke test"

SMOKE_RESULT=$(FRANKENCLAW_KEYS_PATH="$KEYS_PATH" "$PY_BIN" - "$REPO_DIR" "$SMOKE_TOOLS" <<'PY'
import importlib.util
import inspect
import json
import sys
from pathlib import Path

repo = Path(sys.argv[1])
requested = json.loads(sys.argv[2])

sys.path.insert(0, str(repo))

tools_dir = repo / "tools"
if not tools_dir.is_dir():
    print(json.dumps({"error": f"tools dir not found at {tools_dir}"}))
    sys.exit(0)

available = sorted(
    p.stem for p in tools_dir.glob("*.py")
    if not p.stem.startswith("_")
)

if requested == ["all"]:
    targets = available
else:
    targets = [t for t in requested if t in available]

loaded = []
failed = {}
for name in targets:
    try:
        mod = importlib.import_module(f"tools.{name}")
    except Exception as e:
        failed[name] = f"{type(e).__name__}: {e}"
        continue
    funcs = [
        fn for fn, obj in inspect.getmembers(mod, inspect.iscoroutinefunction)
        if not fn.startswith("_")
    ]
    loaded.append({"module": name, "funcs": funcs})

print(json.dumps({
    "available": available,
    "requested": targets,
    "loaded": loaded,
    "failed": failed,
}))
PY
)

LOADED_COUNT=$(echo "$SMOKE_RESULT" | python3 -c 'import sys,json;print(len(json.load(sys.stdin).get("loaded",[])))')
set_step smoke_test "$(echo "$SMOKE_RESULT" | python3 -c 'import sys,json;d=json.load(sys.stdin);d["ok"]=True;print(json.dumps(d))')"

# Always-helpful MCP snippet — caller drops this into their MCP config.
MCP_SNIPPET="$(python3 - "$PY_BIN" "$REPO_DIR" <<'PY'
import json, sys
print(json.dumps({"command": sys.argv[1], "args": [sys.argv[2] + "/server.py"]}))
PY
)"

if [ "$LOADED_COUNT" -ge 1 ]; then
  emit true
else
  emit false "smoke test: no tool modules imported cleanly. See smoke_test.failed for per-module errors."
fi
