#!/usr/bin/env bash
# Adds the fal MCP server to the supported coding agents found on this machine.
#
#   curl -fsSL https://fal.ai/mcp/install.sh | bash
#
# Remote server, OAuth sign-in: nothing is downloaded or run locally, and no
# API key is written to disk. Safe to re-run; agents already set up are skipped.

set -euo pipefail

MCP_NAME="fal"
MCP_URL="https://mcp.fal.ai/mcp-relay"
DOCS_URL="https://fal.ai/docs/documentation/setting-up/mcp"

if [[ -t 1 ]]; then
    GREEN='\033[0;32m'; YELLOW='\033[0;33m'; BLUE='\033[0;34m'; DIM='\033[2m'; BOLD='\033[1m'; NC='\033[0m'
else
    GREEN=''; YELLOW=''; BLUE=''; DIM=''; BOLD=''; NC=''
fi

info()    { printf '%b==>%b %b%s%b\n' "$BLUE" "$NC" "$BOLD" "$1" "$NC"; }
added()   { printf '  %b✓%b %s %b%s%b\n' "$GREEN" "$NC" "$1" "$DIM" "${2:-}" "$NC"; }
skipped() { printf '  %b–%b %s %b%s%b\n' "$DIM" "$NC" "$1" "$DIM" "$2" "$NC"; }
failed()  { printf '  %b!%b %s: %s\n' "$YELLOW" "$NC" "$1" "$2"; FAILED+=("$1"); }

has() { command -v "$1" >/dev/null 2>&1; }
pretty_path() { local t='~'; printf '%s' "${1/#"$HOME"/$t}"; }

CONFIGURED=()
RECOVERY_COPY=""
NEXT_STEPS=()
FAILED=()

configured() {
    added "$1" "${3:-}"
    CONFIGURED+=("$1")
    NEXT_STEPS+=("$1: $2")
}

# Prints the JSON of <file> with .<parent>.fal set to <entry>. Fails, printing
# nothing, if no parser is available or the file is not strict JSON.
json_merged() {
    local file="$1" parent="$2" entry="$3"
    if has jq; then
        jq --arg p "$parent" --arg n "$MCP_NAME" --argjson e "$entry" \
            '.[$p] = ((.[$p] // {}) + {($n): $e})' "$file" 2>/dev/null
    elif has node; then
        node -e '
            const fs = require("fs");
            const [file, parent, name, entry] = process.argv.slice(1);
            const config = JSON.parse(fs.readFileSync(file, "utf8"));
            config[parent] = { ...config[parent], [name]: JSON.parse(entry) };
            process.stdout.write(JSON.stringify(config, null, 2) + "\n");
        ' "$file" "$parent" "$MCP_NAME" "$entry" 2>/dev/null
    elif has_python; then
        python3 -c '
import json, sys
file, parent, name, entry = sys.argv[1:]
with open(file) as f:
    config = json.load(f)
config.setdefault(parent, {})[name] = json.loads(entry)
print(json.dumps(config, indent=2))
' "$file" "$parent" "$MCP_NAME" "$entry" 2>/dev/null
    else
        return 1
    fi
}

# On macOS, /usr/bin/python3 is a stub that opens an install dialog without the
# Command Line Tools.
has_python() {
    has python3 && { [[ "$(uname -s)" != Darwin ]] || xcode-select -p >/dev/null 2>&1; }
}

has_json_tool() { has jq || has node || has_python; }

# Edits the file in place, so symlinks and permissions survive. The first
# backup is kept; later runs never overwrite it. Returns 1 when the file cannot
# be parsed and 2 when it cannot be written; either way the file is unchanged.
# Returns 3 if a failed write could not be undone, with the original kept at
# RECOVERY_COPY.
# Callers run this as a condition, where `set -e` does not apply, so every step
# is checked explicitly.
merge_json() {
    local file="$1" parent="$2" entry="$3" tmp existed=0
    [[ -e "$file" || -L "$file" ]] && existed=1
    tmp="$(mktemp)" || return 2
    if [[ -s "$file" ]]; then
        if ! json_merged "$file" "$parent" "$entry" >"$tmp" || [[ ! -s "$tmp" ]]; then
            rm -f "$tmp"
            return 1
        fi
        if ! (umask 077 && cp "$file" "$tmp.orig") 2>/dev/null ||
            { [[ ! -e "$file.bak" ]] && ! cp -p "$file" "$file.bak" 2>/dev/null; }; then
            rm -f "$tmp" "$tmp.orig"
            return 2
        fi
    else
        echo '{}' >"$tmp"
        if ! json_merged "$tmp" "$parent" "$entry" >"$tmp.out" || [[ ! -s "$tmp.out" ]]; then
            rm -f "$tmp" "$tmp.out"
            return 1
        fi
        mv "$tmp.out" "$tmp"
        if ! mkdir -p "$(dirname "$file")" 2>/dev/null; then
            rm -f "$tmp"
            return 2
        fi
    fi
    if ! { cat "$tmp" >"$file"; } 2>/dev/null; then
        rm -f "$tmp"
        if ((existed)) && [[ ! -e "$tmp.orig" ]]; then
            { : >"$file"; } 2>/dev/null || true
        elif [[ ! -e "$tmp.orig" ]]; then
            rm -f "$file"
        elif ! cmp -s "$tmp.orig" "$file" && ! { cat "$tmp.orig" >"$file"; } 2>/dev/null; then
            RECOVERY_COPY="$tmp.orig"
            return 3
        fi
        rm -f "$tmp.orig"
        return 2
    fi
    rm -f "$tmp" "$tmp.orig"
}

# True when .<parent>.fal exists. Falls back to a key-shaped grep for files a
# strict parser rejects (JSONC with comments or trailing commas).
json_has_server() {
    local file="$1" parent="$2" result=""
    [[ -s "$file" ]] || return 1
    if has jq; then
        result="$(jq -r --arg p "$parent" --arg n "$MCP_NAME" '.[$p][$n] != null' "$file" 2>/dev/null || true)"
    elif has node; then
        result="$(node -e '
            const [file, parent, name] = process.argv.slice(1);
            const config = JSON.parse(require("fs").readFileSync(file, "utf8"));
            console.log(String(config[parent]?.[name] != null));
        ' "$file" "$parent" "$MCP_NAME" 2>/dev/null || true)"
    elif has_python; then
        result="$(python3 -c '
import json, sys
file, parent, name = sys.argv[1:]
config = json.load(open(file))
print("true" if (config.get(parent) or {}).get(name) is not None else "false")
' "$file" "$parent" "$MCP_NAME" 2>/dev/null || true)"
    fi
    case "$result" in
        true) return 0 ;;
        false) return 1 ;;
        *) grep -Eq "\"$MCP_NAME\"[[:space:]]*:" "$file" ;;
    esac
}

# Prints the URL of .<parent>.fal (url, serverUrl or httpUrl). Prints nothing
# when the file cannot be parsed or the entry has no URL.
json_server_url() {
    local file="$1" parent="$2"
    if has jq; then
        jq -r --arg p "$parent" --arg n "$MCP_NAME" \
            '.[$p][$n] | .url // .serverUrl // .httpUrl // empty' "$file" 2>/dev/null
    elif has node; then
        node -e '
            const [file, parent, name] = process.argv.slice(1);
            const server = JSON.parse(require("fs").readFileSync(file, "utf8"))[parent]?.[name] ?? {};
            const url = server.url ?? server.serverUrl ?? server.httpUrl;
            if (typeof url === "string") console.log(url);
        ' "$file" "$parent" "$MCP_NAME" 2>/dev/null
    elif has_python; then
        python3 -c '
import json, sys
file, parent, name = sys.argv[1:]
server = (json.load(open(file)).get(parent) or {}).get(name) or {}
url = server.get("url") or server.get("serverUrl") or server.get("httpUrl")
if isinstance(url, str):
    print(url)
' "$file" "$parent" "$MCP_NAME" 2>/dev/null
    fi
    return 0
}

# Prints the url of the [mcp_servers.fal] table in a TOML file, or nothing
# when the value is not a quoted string.
toml_server_url() {
    awk -v header="[mcp_servers.$MCP_NAME]" '
        {
            line = $0
            sub(/^[[:space:]]+/, "", line)
            sub(/[[:space:]]*(#.*)?$/, "", line)
        }
        line == header { inside = 1; next }
        line ~ /^\[/ { inside = 0 }
        inside && line ~ /^url[[:space:]]*=/ {
            rest = $0
            sub(/^[[:space:]]*url[[:space:]]*=[[:space:]]*/, "", rest)
            quote = substr(rest, 1, 1)
            if (quote == "\"" || quote == "\047") {
                value = substr(rest, 2)
                end = index(value, quote)
                if (end) print substr(value, 1, end - 1)
            }
            exit
        }
    ' "$1" 2>/dev/null || true
}

# Prints the value of the first "url:" line in CLI output such as `mcp get`.
cli_server_url() {
    sed -n 's/^[[:space:]]*[Uu][Rr][Ll]:[[:space:]]*//p' <<<"$1" | head -n 1
}

# Skips an agent whose fal entry uses exactly MCP_URL. An entry with another URL
# (an older fal endpoint, or any other host) is reported, not left in place
# silently.
report_existing() {
    local label="$1" url="$2" remove="$3"
    if [[ "$url" == "$MCP_URL" ]]; then
        skipped "$label" "already configured"
    elif [[ -z "$url" ]]; then
        skipped "$label" "has a fal server, but its URL could not be checked"
    else
        failed "$label" "fal points at $url, not $MCP_URL. Remove it ($remove), then run this again"
    fi
}

setup_json_client() {
    local label="$1" file="$2" parent="$3" entry="$4" next="$5"
    local shown rc=0
    shown="$(pretty_path "$file")"
    if json_has_server "$file" "$parent"; then
        report_existing "$label" "$(json_server_url "$file" "$parent")" "delete \"$MCP_NAME\" from \"$parent\" in $shown"
        return
    fi
    if ! has_json_tool; then
        failed "$label" "needs jq, node or python3. Add to \"$parent\" in $shown: \"$MCP_NAME\": $entry"
        return
    fi
    merge_json "$file" "$parent" "$entry" || rc=$?
    case "$rc" in
        0) configured "$label" "$next" "$shown" ;;
        1) failed "$label" "could not parse $shown (comments or trailing commas?). Add to \"$parent\": \"$MCP_NAME\": $entry" ;;
        2) failed "$label" "could not write $shown. Add to \"$parent\": \"$MCP_NAME\": $entry" ;;
        *) failed "$label" "could not write $shown or restore it. The original is at $RECOVERY_COPY" ;;
    esac
}

setup_claude_code() {
    has claude || return 0
    # Look from an empty directory so that only the user-scope entry counts:
    # a project or local fal entry does not reach the user's other projects.
    local current dir found=0
    dir="$(mktemp -d)" || dir=/
    if current="$(cd "$dir" && claude mcp get "$MCP_NAME" 2>/dev/null)"; then
        found=1
    fi
    [[ "$dir" == / ]] || rmdir "$dir" 2>/dev/null || true
    if ((found)); then
        report_existing "Claude Code" "$(cli_server_url "$current")" "claude mcp remove --scope user $MCP_NAME"
    elif claude mcp add --transport http --scope user "$MCP_NAME" "$MCP_URL" >/dev/null 2>&1; then
        configured "Claude Code" "run /mcp, select fal, choose Authenticate"
    else
        failed "Claude Code" "run: claude mcp add --transport http --scope user $MCP_NAME $MCP_URL"
    fi
}

# Written directly: `codex mcp add` starts a browser sign-in mid-install. The
# table is appended only to a config Codex can read, and Codex must read the
# result back, otherwise the original file is restored.
setup_codex() {
    has codex || return 0
    local config="${CODEX_HOME:-$HOME/.codex}/config.toml" current orig=""
    local manual="run codex mcp add $MCP_NAME --url $MCP_URL (it opens a browser sign-in)"
    if current="$(codex mcp get "$MCP_NAME" </dev/null 2>/dev/null)"; then
        report_existing "Codex CLI" "$(cli_server_url "$current")" "codex mcp remove $MCP_NAME"
        return
    fi
    if grep -qs "^\[mcp_servers\.$MCP_NAME\]" "$config"; then
        report_existing "Codex CLI" "$(toml_server_url "$config")" "codex mcp remove $MCP_NAME"
        return
    fi
    if [[ -s "$config" ]] && ! codex mcp list </dev/null >/dev/null 2>&1; then
        failed "Codex CLI" "could not read $(pretty_path "$config"); $manual"
        return
    fi
    if ! mkdir -p "$(dirname "$config")" 2>/dev/null; then
        failed "Codex CLI" "could not create $(pretty_path "$(dirname "$config")"); $manual"
        return
    fi
    if [[ -e "$config" ]] && { ! orig="$(mktemp)" || ! { cat "$config" >"$orig"; } 2>/dev/null; }; then
        rm -f "$orig"
        failed "Codex CLI" "could not back up $(pretty_path "$config"); $manual"
        return
    fi
    if { printf '\n[mcp_servers.%s]\nurl = "%s"\n' "$MCP_NAME" "$MCP_URL" >>"$config"; } 2>/dev/null &&
        codex mcp get "$MCP_NAME" </dev/null >/dev/null 2>&1; then
        rm -f "$orig"
        configured "Codex CLI" "run codex mcp login $MCP_NAME" "$(pretty_path "$config")"
        return
    fi
    # Undo whatever part of the append landed. Every step is a condition, so a
    # failure here is reported rather than ending the installer.
    if [[ -z "$orig" ]]; then
        rm -f "$config" 2>/dev/null || true
    elif ! cmp -s "$orig" "$config" && ! { cat "$orig" >"$config"; } 2>/dev/null; then
        failed "Codex CLI" "could not add fal to $(pretty_path "$config") or restore it. The original is at $orig"
        return
    fi
    rm -f "$orig"
    failed "Codex CLI" "could not add fal to $(pretty_path "$config"); $manual"
}

setup_gemini() {
    has gemini || return 0
    local settings="$HOME/.gemini/settings.json"
    if json_has_server "$settings" mcpServers; then
        report_existing "Gemini CLI" "$(json_server_url "$settings" mcpServers)" "gemini mcp remove --scope user $MCP_NAME"
    elif gemini mcp add --scope user --transport http "$MCP_NAME" "$MCP_URL" >/dev/null 2>&1; then
        configured "Gemini CLI" "in a trusted folder, run /mcp auth $MCP_NAME"
    else
        failed "Gemini CLI" "run: gemini mcp add --scope user --transport http $MCP_NAME $MCP_URL"
    fi
}

# Other packages also install a `grok` command; only xAI's Grok Build has `grok mcp`.
setup_grok() {
    local grok=""
    if [[ -x "$HOME/.grok/bin/grok" ]]; then
        grok="$HOME/.grok/bin/grok"
    elif has grok && grok mcp --help >/dev/null 2>&1; then
        grok="grok"
    fi
    [[ -n "$grok" ]] || return 0
    if grep -qs "^\[mcp_servers\.$MCP_NAME\]" "$HOME/.grok/config.toml"; then
        report_existing "Grok Build" "$(toml_server_url "$HOME/.grok/config.toml")" "grok mcp remove $MCP_NAME"
    elif "$grok" mcp add --transport http "$MCP_NAME" "$MCP_URL" >/dev/null 2>&1; then
        configured "Grok Build" "run /mcps, select fal, press i to sign in"
    else
        failed "Grok Build" "run: grok mcp add --transport http $MCP_NAME $MCP_URL"
    fi
}

setup_vscode() {
    local user_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Code/User"
    [[ "$(uname -s)" == Darwin ]] && user_dir="$HOME/Library/Application Support/Code/User"
    has code || [[ -d "$user_dir" ]] || return 0
    local entry="{\"type\":\"http\",\"url\":\"$MCP_URL\"}"
    local next="open the MCP Servers view, start fal, and sign in"
    if json_has_server "$user_dir/mcp.json" servers; then
        report_existing "VS Code" "$(json_server_url "$user_dir/mcp.json" servers)" "delete \"$MCP_NAME\" from \"servers\" in its mcp.json"
    elif has code && code --add-mcp "{\"name\":\"$MCP_NAME\",\"type\":\"http\",\"url\":\"$MCP_URL\"}" >/dev/null 2>&1; then
        configured "VS Code" "$next"
    else
        setup_json_client "VS Code" "$user_dir/mcp.json" servers "$entry" "$next"
    fi
}

setup_cursor() {
    [[ -d "$HOME/.cursor" ]] || has cursor || has cursor-agent || return 0
    setup_json_client "Cursor" "$HOME/.cursor/mcp.json" mcpServers \
        "{\"url\":\"$MCP_URL\"}" \
        "open Customize, find fal in the MCP server list, and sign in"
}

# Windsurf became Devin Desktop, which reads both its own and the legacy path.
setup_devin_desktop() {
    local entry="{\"serverUrl\":\"$MCP_URL\"}"
    local next="refresh the MCP list in Cascade and sign in"
    local devin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/devin"
    if [[ -d "$devin_dir" ]]; then
        setup_json_client "Devin Desktop" "$devin_dir/mcp_config.json" mcpServers "$entry" "$next"
    elif [[ -d "$HOME/.codeium/windsurf" ]]; then
        setup_json_client "Windsurf" "$HOME/.codeium/windsurf/mcp_config.json" mcpServers "$entry" "$next"
    fi
}

setup_opencode() {
    local dir="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
    [[ -d "$dir" ]] || has opencode || return 0
    local file="$dir/opencode.json"
    [[ ! -e "$file" && -e "$dir/opencode.jsonc" ]] && file="$dir/opencode.jsonc"
    setup_json_client "opencode" "$file" mcp \
        "{\"type\":\"remote\",\"url\":\"$MCP_URL\",\"enabled\":true}" \
        "run opencode mcp auth $MCP_NAME"
}

main() {
    case "$(uname -s)" in
        Darwin|Linux) ;;
        *)
            echo "This installer supports macOS and Linux. Setup for other systems: $DOCS_URL" >&2
            exit 1
            ;;
    esac

    info "Adding the fal MCP server to your agents"
    setup_claude_code
    setup_codex
    setup_cursor
    setup_vscode
    setup_devin_desktop
    setup_gemini
    setup_grok
    setup_opencode

    echo ""
    if [[ ${#CONFIGURED[@]} -gt 0 ]]; then
        info "Sign in once in each agent"
        for step in "${NEXT_STEPS[@]}"; do
            echo "  $step"
        done
        echo ""
        echo "  Then ask: \"Generate a cinematic video of a fox running through snow.\""
    elif [[ ${#FAILED[@]} -eq 0 ]]; then
        info "Nothing new to configure"
        echo "  No new supported agents found. Server URL for any other client: $MCP_URL"
        echo "  Setup guides: $DOCS_URL"
    fi

    if [[ ${#FAILED[@]} -gt 0 ]]; then
        [[ ${#CONFIGURED[@]} -eq 0 ]] || echo ""
        echo "  Could not configure: ${FAILED[*]}. See the lines marked ! above, or $DOCS_URL"
        [[ ${#CONFIGURED[@]} -gt 0 ]] || exit 1
    fi
}

main "$@"
