#!/usr/bin/env python3
"""Parameterizable fake `claude` binary for SessionRunner tests.

Behaviour is selected via env vars so tests can drive each branch:
  FAKE_CLAUDE_MODE = ready | no_marker | crash | trust_error | slow | stall
                     | resume | stderr_error                              (default ready)
  - resume:       like ready but omits "Created initial session" (a reconnecting
                  bridge doesn't re-log it) -> exercises pointer session backfill
  - stderr_error: writes a failure reason to STDERR (not --debug-file) and exits 1
                  -> exercises clauster's stderr capture into error_detail
  FAKE_CLAUDE_SLOW = seconds to delay before writing markers (mode=slow)
  FAKE_CLAUDE_AGENTS = JSON string returned by `agents --json`        (default [])
  FAKE_CLAUDE_AGENTS_FILE = path re-read on EVERY `agents --json` call (wins over
                  FAKE_CLAUDE_AGENTS; missing/empty file -> []) — lets a test
                  stage/replace the agents list after the server started

`remote-control` writes the real bridge-log marker sequence to --debug-file,
then (mode=ready) idles until SIGINT, on which it logs the shutdown marker and
exits 0 — mimicking the empirically-confirmed clean SIGINT path.
"""
import os
import signal
import sys
import time

ENV_ID = "env_01TESTENVAAAAAAAAAAAAAAAA"
STARTER = "session_01TESTSTARTERAAAAAAAAAA"
BRIDGE_ID = "11111111-2222-3333-4444-555555555555"
# Flag-form (`claude --remote-control`) connect URL session id (pty mode).
PTY_SESSION = "session_01TESTPTYSESSIONAAAAA"


def _idle_until_signal():
    stopped = {"v": False}

    def handler(_signum, _frame):
        stopped["v"] = True

    signal.signal(signal.SIGINT, handler)
    signal.signal(signal.SIGTERM, handler)
    if hasattr(signal, "SIGBREAK"):
        signal.signal(signal.SIGBREAK, handler)
    for _ in range(2400):  # idle up to ~600s waiting for a signal
        if stopped["v"]:
            break
        time.sleep(0.25)
    return 0


def run_flag_bridge(args):
    """The `claude --remote-control <name> [--continue]` flag form (pty mode).

    Single interactive session: prints its connect URL to STDOUT (the PTY keeper
    scrapes it off the master) then idles until SIGINT — unlike the subcommand,
    it writes no env-register markers to --debug-file.
    """
    debug_file = _arg(args, "--debug-file")
    mode = os.environ.get("FAKE_CLAUDE_MODE", "ready")
    if debug_file:
        import json

        with open(debug_file + ".argv.json", "w") as fh:
            json.dump(args, fh)
    if mode == "crash":
        return 1
    if mode == "pty_no_url":
        # Alive but never prints a connect URL -> exercises the STARTING/ERROR path.
        return _idle_until_signal()
    if mode == "pty_slow":
        # Print the URL only after a delay so the bridge is STARTING past the
        # synchronous readiness wait -> the background startup-watch promotes it.
        time.sleep(float(os.environ.get("FAKE_CLAUDE_SLOW", "0.8")))
    # Mimic the real TUI line: "Continue here, on your phone, or at <url>".
    sys.stdout.write(f"\r\n/remote-control is active · Continue at https://claude.ai/code/{PTY_SESSION}\r\n")
    sys.stdout.flush()
    return _idle_until_signal()


def _arg(args, flag):
    return args[args.index(flag) + 1] if flag in args and args.index(flag) + 1 < len(args) else None


def run_bridge(args):
    debug_file = _arg(args, "--debug-file")
    mode = os.environ.get("FAKE_CLAUDE_MODE", "ready")

    # Record the full argv next to the debug file so tests can assert the flags
    # Clauster passed (--spawn / --permission-mode, etc.).
    if debug_file:
        import json

        with open(debug_file + ".argv.json", "w") as fh:
            json.dump(args, fh)

    def log(line):
        if debug_file:
            with open(debug_file, "a") as fh:
                fh.write(line + "\n")

    if mode == "crash":
        return 1
    if mode == "trust_error":
        log("2026-01-01T00:00:00.000Z [ERROR] Workspace not trusted")
        return 1
    if mode == "stderr_error":
        # Failure reason on stderr only (NOT --debug-file), e.g. a controller-auth
        # rejection. clauster routes stdout+stderr to a file and surfaces this.
        sys.stderr.write("Error: failed to authenticate bridge to controller (HTTP 401)\n")
        sys.stderr.flush()
        return 1
    if mode == "stall":
        # Alive but never registers an environment: mimic a bridge that launched
        # yet could not authenticate to the controller (the real "running but
        # unusable" case). Emit only a non-marker debug line, then idle until
        # signalled — no bridge:init/api/work markers are ever written.
        log("2026-01-01T00:00:00.000Z [DEBUG] Failed to read OAuth token: permission denied")
        stalled = {"v": False}

        def _stop(_signum, _frame):
            stalled["v"] = True

        signal.signal(signal.SIGINT, _stop)
        signal.signal(signal.SIGTERM, _stop)
        if hasattr(signal, "SIGBREAK"):  # Windows CTRL_BREAK_EVENT
            signal.signal(signal.SIGBREAK, _stop)
        for _ in range(2400):  # idle up to ~600s waiting for a signal
            if stalled["v"]:
                break
            time.sleep(0.25)
        return 0
    if mode == "slow":
        time.sleep(float(os.environ.get("FAKE_CLAUDE_SLOW", "1.0")))

    log(f"2026-01-01T00:00:00.000Z [DEBUG] [bridge:init] bridgeId={BRIDGE_ID} dir=/x branch=HEAD")
    log(f"2026-01-01T00:00:00.100Z [DEBUG] [bridge:api] POST /v1/environments/bridge -> 200 environment_id={ENV_ID}")
    if mode != "resume":
        # A reconnecting bridge re-attaches to an existing session and does NOT
        # re-log this line — so 'resume' mode omits it and clauster must recover
        # the session id from the bridge-pointer instead.
        log(f"2026-01-01T00:00:00.200Z [DEBUG] [bridge:init] Created initial session {STARTER}")

    if mode == "no_marker":
        return 0  # exits before the poll loop -> never becomes ready

    log(f"2026-01-01T00:00:00.300Z [DEBUG] [bridge:work] Starting poll loop spawnMode=same-dir maxSessions=32 environmentId={ENV_ID}")

    if os.environ.get("FAKE_CLAUDE_LOG_EXTRA"):
        # One extra line carrying ANSI color codes, a session deep link, and a bearer
        # token, so an E2E can assert the WS live-tail strips ANSI and redacts the
        # session id / token (clauster's sanitize_line). Gated behind the env var so it
        # never perturbs the marker-sequence the unit tests assert against.
        log(
            f"\x1b[33m2026-01-01T00:00:00.400Z [DEBUG] [bridge:link] "
            f"Continue at https://claude.ai/code/{PTY_SESSION} "
            f"Authorization: Bearer sk-FAKEFAKEFAKEFAKEFAKE\x1b[0m"
        )

    stopped = {"v": False}

    def handler(_signum, _frame):
        log("2026-01-01T00:00:05.000Z [DEBUG] [bridge:shutdown] SIGINT received, shutting down")
        stopped["v"] = True

    signal.signal(signal.SIGINT, handler)
    signal.signal(signal.SIGTERM, handler)
    if hasattr(signal, "SIGBREAK"):  # Windows CTRL_BREAK_EVENT (graceful stop)
        signal.signal(signal.SIGBREAK, handler)
    for _ in range(2400):  # idle up to ~600s waiting for a signal
        if stopped["v"]:
            break
        time.sleep(0.25)
    return 0


def run_login_shepherd(mode, args):
    """Fake `claude auth login --claudeai` / `claude setup-token` for #839 tests.

    Scripted entirely via env vars so a test can drive each branch without ever
    touching the real CLI/account:
      FAKE_CLAUDE_LOGIN_MODE = ready | no_url | crash_before_url | slow_url
                                | reject_code                        (default ready)
        - ready:            prints an authorize URL, waits for one line of stdin
                             (the pasted code), then prints a success line (+ a
                             canned CLAUDE_CODE_OAUTH_TOKEN= line for setup-token)
                             and exits 0.
        - no_url:           prints ordinary chatter but never an authorize URL,
                             then idles (exercises the fail-closed no-URL path).
        - crash_before_url: exits 1 immediately, no URL ever printed.
        - url_then_exit:    prints a VALID authorize URL, then exits 0 immediately
                             (never blocks on stdin) — the "URL found but process
                             already dead" case; start() must fail closed.
        - url_then_delayed_exit: prints a VALID authorize URL, stays alive
                             FAKE_CLAUDE_LOGIN_DELAYED_EXIT_SECONDS (default 0.3), THEN
                             exits 0 — the DETERMINISTIC form of the "URL seen while the
                             process still looked alive, then it died" race; start()'s
                             liveness settle must catch the exit and fail closed.
        - slow_url:         sleeps FAKE_CLAUDE_LOGIN_SLOW_SECONDS (default 0.3)
                             before printing the URL (exercise the bounded wait).
        - reject_code:      prints the URL, reads the pasted code, then prints a
                             failure line and exits 1 (bad-code path).
        - decoy_url:        prints a NON-Claude https URL (a docs link) BEFORE the
                             real authorize URL — the extractor must prefer the
                             known-host one, not the first match.
        - docs_decoy_url:   prints a docs subdomain of a KNOWN parent
                             (docs.anthropic.com) BEFORE the real authorize URL —
                             the extractor must NOT treat the docs host as an auth
                             host, and return the real claude.ai URL.
        - punct_url:        prints the authorize URL with trailing punctuation
                             ("...authorize?fake=1)." ) — the extractor must trim it.
        - ansi_url:         prints the authorize URL wrapped in ANSI color+reset
                             escapes — the extractor must strip the escapes.
        - hang_after_code:  prints the URL, reads the pasted code, then idles WITHOUT
                             exiting (mimics a slow provider verification) — exercises
                             submit_code's "still verifying, don't kill" path.
      FAKE_CLAUDE_LOGIN_URL   = the authorize URL to print (default a fixed fake URL)
      FAKE_CLAUDE_LOGIN_TOKEN = the CLAUDE_CODE_OAUTH_TOKEN value for setup-token
                                success (default "fake-oauth-token-canned")
    Never echoes the pasted code back to stdout (the real CLI wouldn't either) —
    a test asserting the code never appears in captured output relies on this.
    """
    mode_sel = os.environ.get("FAKE_CLAUDE_LOGIN_MODE", "ready")
    url = os.environ.get("FAKE_CLAUDE_LOGIN_URL", "https://claude.ai/oauth/authorize?fake=1")
    token = os.environ.get("FAKE_CLAUDE_LOGIN_TOKEN", "fake-oauth-token-canned")

    def emit(line):
        sys.stdout.write(line + "\n")
        sys.stdout.flush()

    if mode_sel == "crash_before_url":
        return 1
    if mode_sel == "no_url":
        emit("Checking credentials...")
        return _idle_until_signal()
    if mode_sel == "slow_url":
        time.sleep(float(os.environ.get("FAKE_CLAUDE_LOGIN_SLOW_SECONDS", "0.3")))

    if mode_sel == "url_then_exit":
        # Prints a VALID authorize URL, then exits immediately (0) WITHOUT blocking on
        # stdin — the "URL found but subprocess already dead" case. start() must fail
        # closed here, never hand the operator a URL for a process with no live stdin.
        emit(f"Open this URL to authorize: {url}")
        return 0

    if mode_sel == "url_then_delayed_exit":
        # Prints a VALID authorize URL, stays alive briefly so start()'s wait observes the
        # URL WHILE the process still looks alive (exited=False), THEN exits — the
        # deterministic form of the "URL seen, then the process died" race. start()'s
        # liveness settle must catch this exit and fail closed, never hand back a URL for a
        # subprocess with no live stdin.
        emit(f"Open this URL to authorize: {url}")
        time.sleep(float(os.environ.get("FAKE_CLAUDE_LOGIN_DELAYED_EXIT_SECONDS", "0.3")))
        return 0

    if mode_sel == "decoy_url":
        # A non-Claude docs link printed FIRST; the real authorize URL second.
        emit("See the docs at https://docs.example.com/help for more.")
        emit(f"Open this URL to authorize: {url}")
    elif mode_sel == "docs_decoy_url":
        # A docs subdomain of a KNOWN parent (docs.anthropic.com) printed FIRST — must NOT
        # be mistaken for the auth host; the real authorize URL (claude.ai) is second.
        emit("Read the guide at https://docs.anthropic.com/claude-code first.")
        emit(f"Open this URL to authorize: {url}")
    elif mode_sel == "punct_url":
        # Authorize URL with trailing sentence punctuation the extractor must trim.
        emit(f"Open this URL to authorize: ({url}).")
    elif mode_sel == "ansi_url":
        # Authorize URL wrapped in an ANSI color code + reset the extractor must strip.
        emit(f"Open this URL to authorize: \x1b[36m{url}\x1b[0m")
    else:
        emit(f"Open this URL to authorize: {url}")
    emit("Paste the code you received:")
    try:
        pasted = sys.stdin.readline()
    except (OSError, ValueError):
        pasted = ""
    if not pasted:
        # stdin closed without a code (e.g. cancel()) — exit quietly.
        return 1
    if mode_sel == "hang_after_code":
        # Read the code but never finish verifying: idle until signalled. Exercises the
        # submit-timeout "still verifying" path (the flow must be left active, not killed).
        return _idle_until_signal()
    if mode_sel == "reject_code":
        emit("Error: invalid code")
        return 1
    emit("Login successful.")
    if mode == "setup-token":
        emit(f"CLAUDE_CODE_OAUTH_TOKEN={token}")
    return 0


def run_mcp(args):
    """Fake `claude mcp <verb> ...` for #769 route-level tests (never the real CLI).

    Scripted entirely via env vars so a route test can drive success/conflict/
    not-found without ever touching a real `claude`/account:
      FAKE_CLAUDE_MCP_EXIT_CODE = default exit code to return (default "0")
      FAKE_CLAUDE_MCP_REMOVE_EXIT_CODE  = per-verb override for `remove`
      FAKE_CLAUDE_MCP_ADDJSON_EXIT_CODE = per-verb override for `add-json`
                                  (both let an edit = remove+re-add test make the
                                  remove succeed while the re-add fails — MUST-FIX #4)
      FAKE_CLAUDE_MCP_STDOUT    = stdout text to emit (default "")
      FAKE_CLAUDE_MCP_STDERR    = stderr text to emit (default "")
      FAKE_CLAUDE_MCP_ARGV_FILE = path to record {"argv", "cwd",
                                  "has_client_secret_env"} as JSON, so a test can
                                  assert exactly what clauster invoked — including
                                  that a secret rode the child env, never argv.
    """
    verb = args[0] if args else ""
    if os.environ.get("FAKE_CLAUDE_MCP_ARGV_FILE"):
        import json

        record = {
            "argv": args,
            "cwd": os.getcwd(),
            "has_client_secret_env": "MCP_CLIENT_SECRET" in os.environ,
        }
        with open(os.environ["FAKE_CLAUDE_MCP_ARGV_FILE"], "w") as fh:
            json.dump(record, fh)
    stdout = os.environ.get("FAKE_CLAUDE_MCP_STDOUT", "")
    stderr = os.environ.get("FAKE_CLAUDE_MCP_STDERR", "")
    if stdout:
        sys.stdout.write(stdout)
    if stderr:
        sys.stderr.write(stderr)
    per_verb = {
        "remove": "FAKE_CLAUDE_MCP_REMOVE_EXIT_CODE",
        "add-json": "FAKE_CLAUDE_MCP_ADDJSON_EXIT_CODE",
    }.get(verb)
    if per_verb and os.environ.get(per_verb) is not None:
        return int(os.environ[per_verb])
    return int(os.environ.get("FAKE_CLAUDE_MCP_EXIT_CODE", "0"))


def run_plugin(args):
    """Fake `claude plugin <verb> ...` for #771 route-level tests (never the real CLI).

    Scripted entirely via env vars so a route test can drive success/not-found
    without ever touching a real `claude`/account/marketplace:
      FAKE_CLAUDE_PLUGIN_EXIT_CODE      = default exit code to return (default "0")
      FAKE_CLAUDE_PLUGIN_EXIT_CODE_<KEY> = per-verb override, KEY is the verb
                                  upper-cased (ENABLE/DISABLE/INSTALL/UNINSTALL/
                                  UPDATE/LIST/DETAILS), or for a `marketplace`
                                  subcommand `MARKETPLACE_<SUBVERB>` (e.g.
                                  MARKETPLACE_ADD, MARKETPLACE_REMOVE).
      FAKE_CLAUDE_PLUGIN_STDOUT   = stdout text to emit (default "")
      FAKE_CLAUDE_PLUGIN_STDERR   = stderr text to emit (default "")
      FAKE_CLAUDE_PLUGIN_ARGV_FILE = path to record {"argv", "cwd"} as JSON, so a
                                  test can assert exactly what clauster invoked
                                  (including the spawn cwd, which several #771
                                  routes resolve deliberately per scope).
    """
    verb = args[0] if args else ""
    key = verb.upper().replace("-", "_")
    if verb == "marketplace" and len(args) > 1:
        key = f"MARKETPLACE_{args[1].upper().replace('-', '_')}"
    if os.environ.get("FAKE_CLAUDE_PLUGIN_ARGV_FILE"):
        import json

        record = {"argv": args, "cwd": os.getcwd()}
        with open(os.environ["FAKE_CLAUDE_PLUGIN_ARGV_FILE"], "w") as fh:
            json.dump(record, fh)
    stdout = os.environ.get("FAKE_CLAUDE_PLUGIN_STDOUT", "")
    # The plugins UI reads two distinct list shapes in one surface load (`plugin list
    # --json` -> [{id,enabled}] and `plugin marketplace list --json` -> [{name,source}]),
    # so a single STDOUT can't serve both. These per-verb overrides let an E2E seed each
    # list independently; both fall back to FAKE_CLAUDE_PLUGIN_STDOUT when unset, so the
    # existing route tests are unaffected.
    list_out = os.environ.get("FAKE_CLAUDE_PLUGIN_LIST_STDOUT")
    mkt_out = os.environ.get("FAKE_CLAUDE_PLUGIN_MARKETPLACE_LIST_STDOUT")
    if verb == "list" and list_out is not None:
        stdout = list_out
    elif verb == "marketplace" and len(args) > 1 and args[1] == "list" and mkt_out is not None:
        stdout = mkt_out
    stderr = os.environ.get("FAKE_CLAUDE_PLUGIN_STDERR", "")
    if stdout:
        sys.stdout.write(stdout)
    if stderr:
        sys.stderr.write(stderr)
    per_verb = os.environ.get(f"FAKE_CLAUDE_PLUGIN_EXIT_CODE_{key}")
    if per_verb is not None:
        return int(per_verb)
    return int(os.environ.get("FAKE_CLAUDE_PLUGIN_EXIT_CODE", "0"))


def run_auth(args):
    """Fake `claude auth status --json` for #838 login-detection tests.

    Scripted entirely via env vars so a test can drive each branch without ever
    touching a real `claude`/account:
      FAKE_CLAUDE_AUTH_STDOUT    = stdout text to emit (default '{"loggedIn": true,
                                   "authMethod": "claude.ai", "apiProvider":
                                   "firstParty"}')
      FAKE_CLAUDE_AUTH_EXIT_CODE = exit code to return (default "0")
      FAKE_CLAUDE_AUTH_HANG      = if set, sleep long enough to trip the caller's
                                   subprocess timeout (exercises the timeout path)
      FAKE_CLAUDE_AUTH_HANG_SECONDS = how long to sleep when hanging (default "8",
                                   comfortably beyond the real 5s bound). A test that
                                   patches the bound down should shrink this too — on
                                   Windows the .cmd wrapper's grandchild survives the
                                   timeout-kill and holds the pipe for the WHOLE sleep.

    Only handles the `status` verb; any other auth subcommand is unknown (exit 2),
    matching the stub's strict "never guess" posture.
    """
    if args[:1] != ["status"]:
        sys.stderr.write(f"fake-claude: unknown auth args {args}\n")
        return 2
    if os.environ.get("FAKE_CLAUDE_AUTH_HANG"):
        time.sleep(float(os.environ.get("FAKE_CLAUDE_AUTH_HANG_SECONDS", "8")))  # -> TimeoutExpired
        return 0
    default = '{"loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty"}'
    sys.stdout.write(os.environ.get("FAKE_CLAUDE_AUTH_STDOUT", default))
    return int(os.environ.get("FAKE_CLAUDE_AUTH_EXIT_CODE", "0"))


def main():
    args = sys.argv[1:]
    if args and args[0] == "--version":
        print("2.1.156 (Claude Code)")
        return 0
    if args and args[0] == "agents" and "--json" in args:
        # FAKE_CLAUDE_AGENTS_FILE takes precedence: the file is re-read on EVERY call,
        # so a test can change the agents list AFTER the server (and this env) started
        # — e.g. staging an external session post-startup so rediscover() can't have
        # auto-adopted it (the adopt-click E2E). Missing/empty file ⇒ [].
        agents_file = os.environ.get("FAKE_CLAUDE_AGENTS_FILE")
        if agents_file:
            try:
                with open(agents_file, encoding="utf-8") as fh:
                    content = fh.read().strip()
            except OSError:
                content = ""
            print(content or "[]")
            return 0
        print(os.environ.get("FAKE_CLAUDE_AGENTS", "[]"))
        return 0
    if args[:2] == ["auth", "login"]:
        return run_login_shepherd("login", args[2:])
    if args and args[0] == "auth":
        return run_auth(args[1:])
    if args and args[0] == "remote-control":
        return run_bridge(args)
    if args and args[0] == "--remote-control":
        return run_flag_bridge(args)
    if args and args[0] == "mcp":
        return run_mcp(args[1:])
    if args and args[0] == "plugin":
        return run_plugin(args[1:])
    if args and args[0] == "setup-token":
        return run_login_shepherd("setup-token", args[1:])
    sys.stderr.write(f"fake-claude: unknown args {args}\n")
    return 2


if __name__ == "__main__":
    raise SystemExit(main())
