"""macOS Keychain-held Claude Code OAuth logins.

Claude Code on macOS keeps a config dir's OAuth credential in the login Keychain —
service ``Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)[:8]>``, account = the
macOS user name — whenever the keychain is writable, and DELETES the plaintext
``.credentials.json`` the moment a keychain write lands (its store is "keychain with
a plaintext fallback": the fallback file is removed once the primary holds the
credential). Only sessions that cannot open the keychain — ssh, tmux, launchd
background jobs, where ``security`` exits 36 "user interaction is not allowed" — keep
using the file.

So a per-dir login that was file-based when it was added (an ``add`` run over ssh)
migrates into the Keychain the first time a GUI-session process refreshes its token —
a launchd agent, a Terminal window, a runner-spawned ``claude`` — and from then on the
file is gone. Reading only ``.credentials.json`` then reports a perfectly working
login as "no credentials on this machine": the shim stops selecting it, telemetry
goes dark, and every consumer of ``list --json`` shows it missing.

This module is the one place that knows the keychain layout. Everything else asks
:func:`probe`, which never raises and never prints the secret.

States returned by :func:`probe`:
  present      the item exists and this process could read it (``doc`` is the parsed
               ``{"claudeAiOauth": …}`` document, identical in shape to the file)
  locked       the item exists but this session cannot open the keychain (exit 36) —
               a GUI-session process on this Mac CAN use it; an ssh session cannot
  absent       no such item
  corrupt      the item exists and was readable, but is not a credential document
  unavailable  not macOS, no ``security`` tool, or it failed in a way that says
               nothing about the item (timeout, crash)

Override: ``CLAUDE_MULTIACC_KEYCHAIN=0`` turns the whole lookup off (every dir reads
as ``absent``), ``=1`` forces it on (the test suite runs the real code path on Linux
against a fake ``security``). Unset: on when running on macOS with ``security``
available.

Run directly: ``python3 lib/keychain.py <probe|service|mtime|delete> <config-dir>``.
"""
from __future__ import annotations

import getpass
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time

SERVICE_PREFIX = 'Claude Code-credentials'
# `security` maps OSStatus to an exit code modulo 256.
EXIT_NOT_FOUND = 44        # errSecItemNotFound (-25300)
EXIT_NO_INTERACTION = 36   # errSecInteractionNotAllowed (-25308): keychain locked here
TIMEOUT_S = 8

_ATTR_ACCT = re.compile(r'"acct"<blob>="((?:[^"\\]|\\.)*)"')
_ATTR_MDAT = re.compile(r'"mdat"<timedate>=0x[0-9A-Fa-f]+\s+"(\d{14})Z')


def enabled():
    """Whether keychain lookups run at all in this process (see module doc)."""
    override = os.environ.get('CLAUDE_MULTIACC_KEYCHAIN', '').strip().lower()
    if override in ('0', 'false', 'no', 'off'):
        return False
    if override in ('1', 'true', 'yes', 'on'):
        return shutil.which('security') is not None
    return sys.platform == 'darwin' and shutil.which('security') is not None


def service_name(config_dir):
    """The keychain service Claude Code uses for a CLAUDE_CONFIG_DIR — hashed from the
    path string exactly as the client was given it (no realpath, no trailing slash
    games), which is what the shim passes: ``$ACC_ROOT/acct-NN``."""
    digest = hashlib.sha256(str(config_dir).encode('utf-8')).hexdigest()[:8]
    return f'{SERVICE_PREFIX}-{digest}'


def _run(args, timeout=TIMEOUT_S):
    """(returncode, stdout, stderr); returncode None when the tool could not run."""
    try:
        p = subprocess.run(['security'] + list(args), capture_output=True, text=True,
                           timeout=timeout, stdin=subprocess.DEVNULL)
    except (OSError, subprocess.SubprocessError):
        return None, '', ''
    return p.returncode, p.stdout or '', p.stderr or ''


def _attributes(service):
    """(rc, account, modified_epoch). Attribute reads work on a LOCKED keychain — only
    the secret needs it open — which is what tells 'locked' from 'absent'."""
    rc, out, _err = _run(['find-generic-password', '-s', service])
    if rc != 0:
        return rc, None, None
    acct = _ATTR_ACCT.search(out)
    mdat = _ATTR_MDAT.search(out)
    modified = None
    if mdat:
        try:
            import calendar
            modified = calendar.timegm(time.strptime(mdat.group(1), '%Y%m%d%H%M%S'))
        except (ValueError, OverflowError):
            modified = None
    return rc, (acct.group(1) if acct else None), modified


def probe(config_dir):
    """Never raises, never logs the secret. See the module doc for the states."""
    service = service_name(config_dir)
    result = {'state': 'unavailable', 'service': service, 'doc': None,
              'account': None, 'modified': None}
    if not enabled():
        result['state'] = 'absent' if os.environ.get('CLAUDE_MULTIACC_KEYCHAIN', '') \
            .strip().lower() in ('0', 'false', 'no', 'off') else 'unavailable'
        return result
    rc, out, _err = _run(['find-generic-password', '-s', service, '-w'])
    if rc == 0:
        try:
            doc = json.loads(out.strip())
        except ValueError:
            doc = None
        if isinstance(doc, dict) and isinstance(doc.get('claudeAiOauth'), dict):
            result.update(state='present', doc=doc)
        else:
            result['state'] = 'corrupt'
        return result
    if rc == EXIT_NOT_FOUND:
        result['state'] = 'absent'
        return result
    if rc == EXIT_NO_INTERACTION:
        arc, acct, modified = _attributes(service)
        if arc == 0:
            result.update(state='locked', account=acct, modified=modified)
        elif arc == EXIT_NOT_FOUND:
            result['state'] = 'absent'
        return result
    return result


def item_mtime(config_dir):
    """Epoch of the item's last modification (0 when absent/unknown). Readable even
    when the keychain is locked, so a marker can still self-heal on a newer login."""
    if not enabled():
        return 0
    rc, _acct, modified = _attributes(service_name(config_dir))
    if rc != 0:
        return 0
    return modified or 0


def write(config_dir, doc, account=None):
    """Store ``doc`` the way Claude Code does (``add-generic-password -U``, hex
    payload, same account name as the existing item so no duplicate is created).
    True on success. The document is passed on argv — the same choice the client
    makes when its payload does not fit `security -i`'s line limit; ``ps`` exposure
    lasts milliseconds and is the same window `add-generic-password -w` always had."""
    if not enabled():
        return False
    service = service_name(config_dir)
    if not account:
        _rc, existing, _m = _attributes(service)
        account = existing or _current_user()
    payload = json.dumps(doc, separators=(',', ':')).encode('utf-8').hex()
    rc, _out, _err = _run(['add-generic-password', '-U', '-a', account, '-s', service,
                           '-X', payload])
    return rc == 0


def delete(config_dir):
    """Remove the item. True when it is gone (including when it never existed)."""
    if not enabled():
        return True
    rc, _out, _err = _run(['delete-generic-password', '-s', service_name(config_dir)])
    return rc in (0, EXIT_NOT_FOUND)


def _current_user():
    try:
        return getpass.getuser()
    except Exception:  # noqa: BLE001 — no passwd entry; the keychain item name is cosmetic
        return os.environ.get('USER') or 'claude'


def main(argv):
    if len(argv) != 3 or argv[1] not in ('probe', 'service', 'mtime', 'delete'):
        print('usage: keychain.py <probe|service|mtime|delete> <config-dir>', file=sys.stderr)
        return 2
    verb, config_dir = argv[1], argv[2]
    if verb == 'service':
        print(service_name(config_dir))
        return 0
    if verb == 'mtime':
        print(item_mtime(config_dir))
        return 0
    if verb == 'delete':
        return 0 if delete(config_dir) else 1
    res = probe(config_dir)
    # The secret itself never leaves this process on stdout: callers that need the
    # document import the module. The CLI answers with the state and non-secret facts.
    o = (res.get('doc') or {}).get('claudeAiOauth') or {}
    print(json.dumps({'state': res['state'], 'service': res['service'],
                      'account': res['account'], 'modified': res['modified'],
                      'expires_at': o.get('expiresAt'),
                      'refresh_token_expires_at': o.get('refreshTokenExpiresAt'),
                      'has_refresh_token': bool(o.get('refreshToken'))}))
    return 0


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