"""Shared account-auth audit — the ONE place that decides whether an account's login
still works, so `expired`, `relogin`, `list` and `status` can never disagree with the
shim's selection rule (bin/claude: creds_dead / expired_marked / auth_dead).

States:
  ok       usable right now (live OAuth credential, or a portable server.token)
  expired  has auth material that CANNOT authenticate — needs `claude-accounts relogin`
  token-invalid  a portable setup-token was rejected — needs a new setup-token, not a login
  blocked  authenticates, but its organization has disabled Claude Code subscription
           access — a re-login cannot fix it, so it needs an admin (or removal)
  locked   the OAuth login is in the macOS Keychain and THIS session cannot open it
           (ssh/tmux/background) — it works from the Mac's own GUI session, so it is
           neither dead nor missing; just not usable from here
  missing  no auth on this machine, and this machine is supposed to own the grant
  remote   no auth here, but the manifest says another machine owns it (informational)
  unverified  a setup-token exists but has not passed inference on this machine

Only 'ok' accounts are selectable; everything else is excluded by the shim.

An OAuth login lives either in `<acct>/.credentials.json` or — on macOS, whenever the
client could open the login Keychain — in a Keychain item (lib/keychain.py). Both are
the same document and the same machine-local class; the file is simply gone once the
client has moved it into the Keychain, so a file-only reading calls a working login
"missing" (that is exactly what happened to a whole pool on 2026-08-28).

Run directly for a TSV dump:  python3 lib/audit.py <acc-root> [mac|linux]
Columns: id, email, home, state, label, reason, fix
"""

import hashlib
import json
import os
import re
import sys
import time

# This module is loaded both as a package sibling (`from audit import …` with lib/ on
# sys.path) and by file path (importlib in lib/common.sh's creds_alive); the keychain
# helper sits beside it either way.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import keychain  # noqa: E402

VALID_ID = re.compile(r'acct-\d{2}')


def _mtime(path):
    try:
        return os.path.getmtime(path)
    except OSError:
        return 0.0


def _nonempty(path):
    try:
        return os.path.getsize(path) > 0
    except OSError:
        return False


def marker_reason(mpath):
    """Second line of a marker file, as a human string ('' when absent/unreadable)."""
    try:
        lines = open(mpath, errors='replace').read().splitlines()
    except OSError:
        return ''
    return lines[1].strip() if len(lines) > 1 else ''


def marker_slug(detail):
    """The reason=<slug> field of a marker line ('' when absent)."""
    m = re.search(r'reason=([A-Za-z0-9._-]+)', detail or '')
    return m.group(1) if m else ''


def expired_marked(d, now=None):
    """True when <d>/.expired is still in force. Mirrors the shim exactly:
    a CREDENTIAL-scoped park clears as soon as a newer credential lands (re-login, or a
    refresh by another process); a POLICY park (org-blocked) does not, because a token
    refresh says nothing about whether the org re-enabled Claude Code. Either kind also
    expires at its own soft_until when the shim wrote it from a single failed run."""
    now = time.time() if now is None else now
    mpath = os.path.join(d, '.expired')
    if not os.path.isfile(mpath):
        return False
    detail = marker_reason(mpath)
    slug = marker_slug(detail)
    if slug == 'setup-token-invalid':
        # A TOKEN park heals only when a NEW token lands (bin/claude expired_marked):
        # a login refresh rewriting .credentials.json or the Keychain item says
        # nothing about the portable token.
        mt = _mtime(mpath)
        p = os.path.join(d, 'server.token')
        if os.path.isfile(p) and _mtime(p) > mt:
            return False
    elif slug != 'org-blocked':
        mt = _mtime(mpath)
        for name in ('.credentials.json', 'server.token'):
            p = os.path.join(d, name)
            if os.path.isfile(p) and _mtime(p) > mt:
                return False
        # A login the client keeps in the Keychain is "written after the marker" in
        # exactly the same sense — its modification stamp is readable even when this
        # session cannot open the secret.
        if not os.path.isfile(os.path.join(d, '.credentials.json')) \
                and keychain.item_mtime(d) > mt:
            return False
    m = re.search(r'soft_until=(\d+)', detail)
    if m and int(m.group(1)) <= now:
        return False
    return True


def _ms(value):
    """Milliseconds field -> seconds. Anything non-numeric reads as 0 (absent) rather
    than raising: one weird credential must never take down the whole audit."""
    try:
        return float(value or 0) / 1000.0
    except (TypeError, ValueError):
        return 0.0


def _scrape(cpath):
    """The shim's rule for a credential JSON cannot parse: sed out the numbers and the
    presence of a refresh token (bin/claude: cred_num / creds_dead). Used so a
    half-written file is judged the same way on both sides — audit.py must never call
    an account dead that the shim is happily selecting."""
    try:
        txt = open(cpath, errors='replace').read()
    except OSError:
        return None
    def num(key):
        m = re.search(r'"%s"\s*:\s*(\d+)' % key, txt)
        return float(m.group(1)) / 1000.0 if m else 0.0
    return {'exp': num('expiresAt'), 'rexp': num('refreshTokenExpiresAt'),
            'has_refresh': bool(re.search(r'"refreshToken"\s*:\s*"[^"]', txt))}


def creds_doc_state(doc, now):
    """(state, reason) for a parsed credential document ({"claudeAiOauth": …}) —
    the one rule for a file AND a Keychain item, so the two stores can never be
    judged differently."""
    o = doc.get('claudeAiOauth', {}) if isinstance(doc, dict) else None
    if not isinstance(o, dict):
        raise ValueError('claudeAiOauth is not an object')
    exp, rexp = _ms(o.get('expiresAt')), _ms(o.get('refreshTokenExpiresAt'))
    has_refresh = bool(o.get('refreshToken'))
    if exp > now:
        return 'ok', 'oauth credential valid'
    if not has_refresh:
        return 'expired', 'access token expired and there is no refresh token'
    if rexp and rexp <= now:
        days = max(0, int((now - rexp) / 86400))
        when = time.strftime('%Y-%m-%d', time.gmtime(rexp))
        return 'expired', f'refresh token expired {when} ({days}d ago)'
    return 'ok', 'access token stale but auto-refreshes'


def creds_state(cpath, now):
    """(state, reason) for an on-disk .credentials.json."""
    scraped = None
    try:
        return creds_doc_state(json.load(open(cpath)), now)
    except Exception as e:
        # Unparseable (truncated / mid-write): fall back to the shim's lenient scrape so
        # both sides agree. Only if that finds nothing usable is the account called dead.
        scraped = _scrape(cpath)
        if not scraped:
            return 'expired', f'credentials unreadable ({str(e)[:60]})'
        exp, rexp, has_refresh = scraped['exp'], scraped['rexp'], scraped['has_refresh']
        if exp <= now and not (has_refresh and (not rexp or rexp > now)):
            return 'expired', f'credentials unreadable ({str(e)[:60]})'
        return 'ok', 'credentials unreadable but carry a live token — left in the pool'


def oauth_login(d, now):
    """Where <d>'s OAuth login lives and whether it can authenticate.

    Returns {'store': 'file'|'keychain'|None, 'state': 'ok'|'expired'|'locked'|None,
             'reason': str, 'doc': dict|None}. The FILE wins when both exist: it is what
    a session without keychain access (ssh) will use, and the client itself reads the
    keychain first only when it can open it. 'locked' means the login exists in the
    Keychain but this session cannot open it."""
    cpath = os.path.join(d, '.credentials.json')
    if _nonempty(cpath):
        state, reason = creds_state(cpath, now)
        return {'store': 'file', 'state': state, 'reason': reason, 'doc': None}
    kc = keychain.probe(d)
    if kc['state'] == 'present':
        try:
            state, reason = creds_doc_state(kc['doc'], now)
        except ValueError as e:
            state, reason = 'expired', f'keychain credential unreadable ({str(e)[:60]})'
        return {'store': 'keychain', 'state': state, 'reason': f'{reason} (macOS Keychain)',
                'doc': kc['doc']}
    if kc['state'] == 'corrupt':
        return {'store': 'keychain', 'state': 'expired',
                'reason': 'keychain credential is not a credential document', 'doc': None}
    if kc['state'] == 'locked':
        return {'store': 'keychain', 'state': 'locked',
                'reason': ('OAuth login is in the macOS Keychain, which this session '
                           'cannot open (ssh/background) — it works from the Mac\'s own '
                           'session'), 'doc': None}
    return {'store': None, 'state': None, 'reason': '', 'doc': None}


LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'token-invalid': 'TOKEN INVALID',
          'blocked': 'BLOCKED', 'locked': 'KEYCHAIN LOCKED', 'missing': 'NO LOGIN',
          'remote': 'ELSEWHERE', 'unverified': 'UNVERIFIED'}

# `import --home` speaks mac|server; machine_kind() speaks mac|linux. Same two boxes,
# two vocabularies — normalize, or an un-authenticated account on the very machine that
# owns it would be filed as "lives elsewhere" and silently drop off the worklist.
_MACHINE_ALIASES = {'mac': 'mac', 'macos': 'mac', 'darwin': 'mac', 'osx': 'mac',
                    'server': 'linux', 'linux': 'linux', 'ubuntu': 'linux',
                    'debian': 'linux', 'remote': 'linux'}


def _norm_machine(name):
    return _MACHINE_ALIASES.get(str(name or '').strip().lower(), '')


def _elsewhere(home, machine):
    """True only when the manifest names a DIFFERENT machine as the grant's owner.
    An unknown/blank home is never treated as 'elsewhere' — that would hide a real gap."""
    h, m = _norm_machine(home), _norm_machine(machine)
    return bool(h) and bool(m) and h != m


def _fix_for(state, aid):
    if state == 'token-invalid':
        return f'claude-accounts login {aid} --token'
    if state == 'blocked':
        # A fresh sign-in re-issues the grant and in practice clears this, so it is
        # handled like any other dead login. If it comes back BLOCKED after a re-login,
        # the org really has Claude Code switched off and an admin has to enable it.
        return (f'claude-accounts relogin {aid}   '
                f'(if it stays BLOCKED, an admin must enable Claude Code for it)')
    if state in ('expired', 'missing'):
        return f'claude-accounts relogin {aid}'
    if state == 'locked':
        # Not a dead login: the Mac's own session can use it as it is. What an operator
        # stuck in an ssh session CAN do is mint the portable token from here — the
        # setup-token ceremony is a fresh browser grant and never reads the keychain.
        return f'claude-accounts mint {aid}   (portable; or use it from the Mac\'s GUI session)'
    if state == 'unverified':
        return 'claude-accounts verify'
    return ''


def _token_verified(d, tpath):
    """Whether this exact setup-token passed an inference on this machine."""
    try:
        digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
        saved = open(os.path.join(d, '.server-token-verified')).read().strip()
        return bool(digest) and saved == digest
    except OSError:
        return False


# The report (`list --json`) tells the panel the same thing per Mac: a Mac reads
# "active" for any token FILE it can see, and only this says whether a real call
# ever succeeded with it here.
token_verified = _token_verified


def audit_account(root, acct, now=None, machine=None, require_verified_token=False):
    now = time.time() if now is None else now
    aid = acct.get('id', '')
    d = os.path.join(root, aid)
    tpath = os.path.join(d, 'server.token')
    has_token = _nonempty(tpath)
    home = acct.get('home', '?')
    row = {'id': aid, 'email': acct.get('email', ''), 'home': home,
           'state': 'ok', 'reason': '', 'store': None}

    def done(r):
        r['label'] = LABELS.get(r['state'], r['state'].upper())
        r['fix'] = _fix_for(r['state'], aid)
        return r

    if expired_marked(d, now=now):
        detail = marker_reason(os.path.join(d, '.expired'))
        slug = marker_slug(detail)
        if slug == 'org-blocked':
            row['state'] = 'blocked'
            row['reason'] = ('this account\'s organization has disabled Claude Code '
                             'subscription access')
        elif slug == 'setup-token-invalid':
            login = oauth_login(d, now)
            if login['state'] == 'ok':
                # A token park says nothing about the working login beside it: the
                # shim runs this account on the login here (bin/claude
                # expired_marked), so this machine reads it as usable — while the
                # token stays unproven for the fleet (token_verified False).
                row['store'] = login['store']
                row['state'] = 'ok'
                row['reason'] = (f"{login['reason']}; portable setup-token was rejected "
                                 "here and is not used")
                return done(row)
            row['state'] = 'token-invalid'
            row['reason'] = 'portable setup-token was rejected by a real inference'
        else:
            row['state'] = 'expired'
            row['reason'] = f'marked dead by the pool ({detail or "authentication failed"})'
        return done(row)
    login = oauth_login(d, now)
    row['store'] = login['store']
    if login['state'] in ('ok', 'expired'):
        state, reason = login['state'], login['reason']
        if state == 'ok' or has_token:
            # A portable token authenticates on its own, so a dead credential beside it
            # is not fatal — same rule the shim applies.
            if state != 'ok' and require_verified_token and not _token_verified(d, tpath):
                row['state'] = 'unverified'
                row['reason'] = 'server.token has not passed an inference on this machine'
            else:
                row['state'] = 'ok'
                row['reason'] = reason if state == 'ok' else f'{reason}; using server.token'
        else:
            row['state'], row['reason'] = 'expired', reason
        return done(row)
    if has_token:
        age = int((now - _mtime(tpath)) / 86400)
        if require_verified_token and not _token_verified(d, tpath):
            row['state'] = 'unverified'
            row['reason'] = (f'server.token present (minted ~{age}d ago) but has not passed '
                             'an inference on this machine')
        else:
            row['reason'] = f'server.token present (minted ~{age}d ago)'
        return done(row)
    if login['state'] == 'locked':
        row['state'], row['reason'] = 'locked', login['reason']
        return done(row)
    if machine and _elsewhere(home, machine):
        row['state'] = 'remote'
        row['reason'] = f'no auth here — the grant lives on the {home} machine'
    else:
        row['state'] = 'missing'
        row['reason'] = 'no credentials on this machine'
    return done(row)


def audit_all(root, machine=None, now=None):
    try:
        doc = json.load(open(os.path.join(root, 'accounts.json')))
    except Exception:
        return []
    out = []
    for a in doc.get('accounts', []):
        if isinstance(a, dict) and VALID_ID.fullmatch(str(a.get('id', ''))):
            out.append(audit_account(root, a, now=now, machine=machine))
    return out


if __name__ == '__main__':
    root = sys.argv[1]
    machine = sys.argv[2] if len(sys.argv) > 2 else None
    strict_tokens = len(sys.argv) > 3 and sys.argv[3] == 'strict-tokens'
    for a in json.load(open(os.path.join(root, 'accounts.json'))).get('accounts', []):
        if not isinstance(a, dict) or not VALID_ID.fullmatch(str(a.get('id', ''))):
            continue
        r = audit_account(root, a, machine=machine, require_verified_token=strict_tokens)
        # Tabs are the field separator, so no field may contain one.
        print('\t'.join(str(r[k]).replace('\t', ' ')
                        for k in ('id', 'email', 'home', 'state', 'label', 'reason', 'fix')))
