"""Does a FRESH shell resolve `claude` / `codex` to this addon's shims?

The shims shadow the real binaries by PATH ORDER, and the rc-file block that puts them
first is only as good as the shell startup that runs it. Three ways it silently is not:
a ~/.profile with no block (every `bash -l` / `sh -l` on my-mini resolved the real
binary, 2026-09-17), an rc file interrupted half-way (a Ctrl-C at a slow `conda` hook
left ~/.local/bin in front, and the next `claude` at that prompt opened on the
un-pooled ~/.claude login: "Not logged in · Please run /login"), or a later
`export PATH=` that re-prepends ~/.local/bin. Nothing inside the shim can notice a
launch that never reached it, so this is checked from the OUTSIDE: start each login
shell the way a terminal or a `bash -lc` launcher would — clean environment, the user's
own rc files — and ask what the command names resolve to.

Interactive modes (`-li`) are fed the probe on stdin, so the shell runs its prompt loop
and the rc block's prompt hook (lib/install_actions.sh path_guard_body) gets its turn,
exactly as it does for a person at a prompt. The `-lc` modes are what a launcher that
runs `bash -lc claude` gets: rc order alone, no prompt hook.

What is measured is the PATH search — `whence -p` / `type -P`, aliases and functions set
aside — because the incident was a PATH-order failure and an `alias claude='claude
--flags'` still resolves through PATH. A function or alias that names the real binary
outright is not detected here; it is also not something an installer can fix.

Every answer line carries a sentinel, the first answer per name wins, and a name the
shell never answered for is an ERROR on that row, not a bypass: an rc file that reads a
line of stdin (an update prompt, a `read`) can eat part of the fed script. Nothing
touches the operator's shell history — the script disables it first thing.

probe(repo_dir) -> [ {shell, mode, argv, results: {name: {resolved, ok}}, error} ... ]
    ok is True for the shim, False for anything else (the real binary, or nothing on
    PATH at all), None when the shell could not answer (timeout / no output).
bypassed(rows) -> the (mode, name, resolved) triples with ok False.

Run directly:  python3 lib/shim_path.py <repo-dir> [--json] [--timeout=SECONDS]
Prints the report; exit 1 when any mode bypasses the shim, 2 on usage, else 0.
CLAUDE_MULTIACC_PATH_PROBE=0 makes probe() return [] (the sandboxed suites run with the
operator's real HOME and must not depend on its rc files).
"""
from __future__ import annotations

import getpass
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor

NAMES = ('claude', 'codex')
# The marker every shim carries in its header; lib/common.sh is_shim_file greps the
# same word, so a /usr/local/bin/claude -> shim symlink (Linux) counts as the shim too.
SHIM_MARK = 'multiacc-shim'
DEFAULT_TIMEOUT = 25
# (shell, flags, interactive) — a terminal is a login AND interactive shell; a
# `bash -lc` launcher is login only; sh has no interactive rc worth probing.
MODES = (
    ('zsh', '-li', True), ('zsh', '-lc', False),
    ('bash', '-li', True), ('bash', '-lc', False),
    ('sh', '-lc', False),
)
# What resolves a NAME to the executable a shell would run, per shell, bypassing
# aliases and functions (an interactive zsh answers `command -v claude` with the
# user's alias text, which says nothing about the file). POSIX sh has only `command -v`,
# which reports an alias or a function by name, so the sh script drops those first.
_RESOLVERS = {'zsh': 'whence -p', 'bash': 'type -P', 'sh': 'command -v'}
SENTINEL = '__claude_multiacc_probe__'
# The PATH a login shell STARTS from before any rc file runs — what sshd/login/launchd
# hand it. /usr/local/bin matters on Linux: the root install's /usr/local/bin/claude
# symlink is how a shell with no rc block still reaches the shim, so leaving it out
# would call a working server login a bypass. macOS's path_helper rebuilds PATH from
# /etc/paths anyway.
_SEED_PATH = {'darwin': '/usr/bin:/bin:/usr/sbin:/sbin'}
_SEED_PATH_DEFAULT = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'


def enabled():
    return os.environ.get('CLAUDE_MULTIACC_PATH_PROBE', '').strip().lower() \
        not in ('0', 'false', 'no', 'off')


def shell_path(name):
    """The shell binary a terminal would start, or None when it is not installed."""
    found = shutil.which(name, path='/bin:/usr/bin:/usr/local/bin:/opt/homebrew/bin')
    if found:
        return found
    candidate = os.path.join('/bin', name)
    return candidate if os.access(candidate, os.X_OK) else None


def _script(shell, names):
    resolver = _RESOLVERS[shell]
    # First thing, before any answer: no history. The interactive shells would otherwise
    # save these lines into the operator's real ~/.zsh_history / ~/.bash_history on exit
    # (macOS /etc/zshrc sets HISTFILE unconditionally, so an env var is not enough — this
    # runs AFTER the rc files and wins). bash honours `unset HISTFILE`; zsh honours
    # SAVEHIST=0. The stderr redirect swallows "no such option" from the other shell.
    # `set +o history` is bash-only: zsh's `set` rejects the option, and a NON-interactive
    # zsh exits on a special-builtin error — the whole `-lc` row would read as "exited
    # without answering". zsh needs nothing beyond SAVEHIST=0.
    lines = ['unset HISTFILE 2>/dev/null; HISTFILE=/dev/null; SAVEHIST=0; HISTSIZE=0; '
             '[ -z "${BASH_VERSION:-}" ] || set +o history; true']
    if shell == 'sh':
        # `command -v` answers with alias text or a bare function name; the PATH search
        # is the question, so those go first (the shell is a throwaway).
        lines.append('unalias -a 2>/dev/null; unset -f ' + ' '.join(names) + ' 2>/dev/null; true')
    lines += [f'printf \'%s %s=%s\\n\' {SENTINEL} {n} "$({resolver} {n} 2>/dev/null)"'
              for n in names]
    # The interactive shells read this on stdin; without the exit an rc file that
    # left the shell in a state where EOF is ignored (IGNOREEOF) would hang to timeout.
    lines.append('exit 0')
    return '\n'.join(lines) + '\n'


def _env(home, user, shell):
    # A TERM a terminal would set: rc files commonly bail out early on TERM=dumb (the
    # Emacs TRAMP guard), which would judge a branch no person at a prompt ever sees.
    return {'HOME': home, 'USER': user, 'LOGNAME': user, 'SHELL': shell,
            'TERM': 'xterm-256color',
            'PATH': _SEED_PATH.get(sys.platform, _SEED_PATH_DEFAULT),
            'LANG': os.environ.get('LANG', 'C.UTF-8'),
            'CLAUDE_MULTIACC_PATH_PROBE': '1'}


def is_shim(path, repo_dir, name):
    if not path:
        return False
    try:
        if os.path.realpath(path) == os.path.realpath(os.path.join(repo_dir, 'bin', name)):
            return True
        with open(path, 'rb') as f:
            return SHIM_MARK.encode() in f.read(300)
    except OSError:
        return False


def _parse(text, names):
    """First sentinel answer per name. Anything else the shell printed is ignored."""
    answers = {}
    for line in text.splitlines():
        if not line.startswith(SENTINEL + ' '):
            continue
        body = line[len(SENTINEL) + 1:]
        name, _sep, value = body.partition('=')
        if name in names and name not in answers:
            answers[name] = value.strip()
    return answers


def _run_mode(shell, flags, interactive, names, repo_dir, home, user, timeout):
    exe = shell_path(shell)
    mode = f'{shell} {flags}'
    row = {'shell': shell, 'mode': mode, 'interactive': interactive, 'argv': None,
           'results': {}, 'error': None}
    if not exe:
        row['error'] = 'shell not installed'
        return row
    script = _script(shell, names)
    argv = [exe, flags] if interactive else [exe, flags, script]
    row['argv'] = argv
    try:
        text, timed_out, returncode = _run_shell(argv, script if interactive else '',
                                                 _env(home, user, exe), home, timeout)
    except Exception as e:  # noqa: BLE001 — one mode must never take down the report
        row['error'] = f'probe could not run: {e!r}'[:200]
        return row
    answers = _parse(text, names)
    missing = [n for n in names if n not in answers]
    if timed_out and missing:
        row['error'] = f'no answer within {timeout}s (an rc file is hanging or prompting)'
        return row
    if not answers:
        row['error'] = (f'shell exited {returncode} without answering '
                        '(an rc file exec\'d something, exited early, or read the probe)')
        return row
    for n in names:
        if n in answers:
            resolved = answers[n] or None
            row['results'][n] = {'resolved': resolved, 'ok': is_shim(resolved, repo_dir, n)}
        else:
            row['results'][n] = {'resolved': None, 'ok': None}
    if missing:
        row['error'] = (f'no answer for {", ".join(missing)} — an rc file consumed part of '
                        'the probe (a prompt or `read` on stdin?)')
    return row


def _run_shell(argv, stdin_text, env, cwd, timeout):
    """(stdout text, timed_out, returncode). stdout goes to a temp FILE, not a pipe:
    a pipe only closes when every process holding it is gone, so an rc line that
    backgrounds something (`ssh-agent`, `tmux new -d`, `cmd &`) would keep a pipe open
    long after the shell answered and exited, and the whole answer would be lost to
    the timeout. The shell gets its own session so a timeout can kill everything it
    started. Whatever was written before a timeout is still returned."""
    with tempfile.TemporaryFile() as out:
        p = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=out, stderr=subprocess.DEVNULL,
                             env=env, cwd=cwd if os.path.isdir(cwd) else None,
                             start_new_session=True)
        timed_out = False
        try:
            p.communicate(stdin_text.encode('utf-8'), timeout=timeout)
        except subprocess.TimeoutExpired:
            timed_out = True
            try:
                os.killpg(p.pid, signal.SIGKILL)
            except OSError:
                pass
            try:
                p.wait(timeout=5)
            except subprocess.TimeoutExpired:
                pass
        out.seek(0)
        text = out.read().decode('utf-8', errors='replace')
    return text, timed_out, p.returncode


def probe(repo_dir, home=None, user=None, names=NAMES, timeout=DEFAULT_TIMEOUT, modes=MODES):
    if not enabled():
        return []
    home = home or os.path.expanduser('~')
    try:
        user = user or getpass.getuser()
    except Exception:  # noqa: BLE001 — no passwd entry; the name only seeds $USER
        user = os.environ.get('USER') or 'user'
    with ThreadPoolExecutor(max_workers=len(modes)) as pool:
        return list(pool.map(
            lambda m: _run_mode(m[0], m[1], m[2], names, repo_dir, home, user, timeout), modes))


def bypassed(rows):
    out = []
    for row in rows:
        for name, r in row['results'].items():
            if r['ok'] is False:
                out.append((row['mode'], name, r['resolved']))
    return out


def render(rows):
    """Operator-facing lines. The verdict line is the one that matters; the per-mode
    lines say WHICH shell to open to reproduce it."""
    lines = ['shim on PATH (fresh login shells, this user\'s rc files):']
    if not rows:
        lines.append('  (probe disabled: CLAUDE_MULTIACC_PATH_PROBE=0)')
        return lines
    for row in rows:
        cell = f'  {row["mode"]:<8}'
        if row['error']:
            lines.append(f'{cell} ?  {row["error"]}')
            continue
        parts = []
        for name, r in row['results'].items():
            if r['ok'] is None:
                parts.append(f'{name}: ?')
            elif r['ok']:
                parts.append(f'{name}: OK')
            elif r['resolved']:
                parts.append(f'{name}: BYPASSED -> {r["resolved"]}')
            else:
                parts.append(f'{name}: NOT ON PATH')
        lines.append(f'{cell} {"   ".join(parts)}' + (f'   ({row["error"]})' if row['error'] else ''))
    bad = bypassed(rows)
    if bad:
        modes = sorted({m for m, _n, _r in bad})
        lines.append(f'  ** {len(modes)} shell mode(s) start `claude`/`codex` OUTSIDE the pool '
                     f'({", ".join(modes)}): such a session runs the real binary on the '
                     'machine\'s own ~/.claude login — or on no login at all ("Not logged '
                     'in · Please run /login"). **')
        lines.append('     fix: claude-multiacc install   # rewrites the rc blocks (~/.zshenv, '
                     '~/.zprofile, ~/.zshrc, ~/.profile, bash rc files) with the prompt hook')
        lines.append('     then open a new shell (or run: exec "$SHELL" -l) — an already-open '
                     'shell keeps the PATH it has')
    unanswered = [row['mode'] for row in rows
                  if row['error'] and row['error'] != 'shell not installed' and not row['results']]
    if unanswered:
        lines.append(f'  note: {", ".join(unanswered)} could not be probed — open one by hand '
                     'and run: command -v claude')
    return lines


def main(argv):
    args = [a for a in argv[1:] if not a.startswith('--')]
    flags = [a for a in argv[1:] if a.startswith('--')]
    if len(args) != 1 or any(f not in ('--json',) and not f.startswith('--timeout=') for f in flags):
        print('usage: shim_path.py <repo-dir> [--json] [--timeout=SECONDS]', file=sys.stderr)
        return 2
    timeout = DEFAULT_TIMEOUT
    for f in flags:
        if f.startswith('--timeout='):
            try:
                timeout = max(1, int(f.split('=', 1)[1]))
            except ValueError:
                print('usage: --timeout takes an integer number of seconds', file=sys.stderr)
                return 2
    rows = probe(args[0], timeout=timeout)
    if '--json' in flags:
        print(json.dumps({'repo_dir': args[0], 'enabled': enabled(), 'modes': rows,
                          'bypassed': [list(b) for b in bypassed(rows)]}, indent=2))
    else:
        print('\n'.join(render(rows)))
    return 1 if bypassed(rows) else 0


if __name__ == '__main__':
    sys.exit(main(sys.argv))
