#!/usr/bin/env python3
"""Unit tests for lib/keychain.py and its audit/report integration.

Runs everywhere: a fake `security` on PATH serves generic-password items from a
temp dir, and CLAUDE_MULTIACC_KEYCHAIN=1 forces the lookup on off-macOS. Nothing
here can ever touch a real keychain — the fake shadows /usr/bin/security.
"""

from __future__ import annotations

import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest

REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / 'lib'))

FAKE_SECURITY = r'''#!/usr/bin/env bash
KC="${FAKE_KEYCHAIN_DIR:-/nonexistent-keychain}"
cmd="${1:-}"; shift || true
svc=""; acct=""; want_pw=0; hexdata=""
while [ $# -gt 0 ]; do
  case "$1" in
    -s) svc="$2"; shift 2 ;;
    -a) acct="$2"; shift 2 ;;
    -w) want_pw=1; shift ;;
    -X) hexdata="$2"; shift 2 ;;
    *) shift ;;
  esac
done
case "$cmd" in
  find-generic-password)
    [ -f "$KC/$svc" ] || exit 44
    if [ "$want_pw" = "1" ]; then
      [ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
      cat "$KC/$svc"; exit 0
    fi
    a="tester"; [ -f "$KC/$svc.acct" ] && a="$(cat "$KC/$svc.acct")"
    printf 'attributes:\n    "acct"<blob>="%s"\n' "$a"
    printf '    "mdat"<timedate>=0x00  "20260828043137Z\\000"\n'
    printf '    "svce"<blob>="%s"\n' "$svc"
    exit 0 ;;
  add-generic-password)
    [ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
    mkdir -p "$KC"
    printf '%s' "$hexdata" | python3 -c 'import sys;sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read().strip()))' > "$KC/$svc"
    printf '%s' "${acct:-tester}" > "$KC/$svc.acct"
    exit 0 ;;
  delete-generic-password)
    [ -f "$KC/$svc" ] || exit 44
    rm -f "$KC/$svc" "$KC/$svc.acct"; exit 0 ;;
  *) exit 1 ;;
esac
'''

DOC = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-x', 'refreshToken': 'r',
                         'expiresAt': 9999999999999,
                         'refreshTokenExpiresAt': 9999999999999}}


class KeychainTests(unittest.TestCase):
    def setUp(self):
        self.work = tempfile.TemporaryDirectory()
        work = Path(self.work.name)
        fakebin = work / 'bin'
        fakebin.mkdir()
        tool = fakebin / 'security'
        tool.write_text(FAKE_SECURITY)
        tool.chmod(0o755)
        self.kcdir = work / 'keychain'
        self.saved = {k: os.environ.get(k) for k in
                      ('PATH', 'CLAUDE_MULTIACC_KEYCHAIN', 'FAKE_KEYCHAIN_DIR',
                       'FAKE_KEYCHAIN_LOCKED')}
        os.environ['PATH'] = f"{fakebin}:{os.environ['PATH']}"
        os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
        os.environ['FAKE_KEYCHAIN_DIR'] = str(self.kcdir)
        os.environ.pop('FAKE_KEYCHAIN_LOCKED', None)
        # (re)load with the fake in place
        import importlib
        import keychain
        self.keychain = importlib.reload(keychain)
        self.root = work / 'accounts'
        (self.root / 'acct-01').mkdir(parents=True)
        (self.root / 'acct-01' / 'tmp').mkdir(exist_ok=True)

    def tearDown(self):
        for key, value in self.saved.items():
            if value is None:
                os.environ.pop(key, None)
            else:
                os.environ[key] = value
        self.work.cleanup()

    # -- the layout facts this whole feature stands on ------------------------------
    def test_service_name_matches_the_client(self):
        # Observed live 2026-08-28 (Claude Code 2.1.250, my-mini): this config dir's
        # login sat under exactly this service. The hash input is the PATH STRING as
        # the client received it — no realpath, no trailing slash.
        self.assertEqual(
            self.keychain.service_name('/Users/gas/.claude-accounts/acct-16'),
            'Claude Code-credentials-4219e2b1')

    def test_absent_then_present_roundtrip(self):
        d = str(self.root / 'acct-01')
        self.assertEqual(self.keychain.probe(d)['state'], 'absent')
        self.assertTrue(self.keychain.write(d, DOC, account='gas'))
        res = self.keychain.probe(d)
        self.assertEqual(res['state'], 'present')
        self.assertEqual(res['doc'], DOC)
        # -U update path keeps a single item and the account name it was made with
        doc2 = {'claudeAiOauth': dict(DOC['claudeAiOauth'], accessToken='sk-ant-oat01-y')}
        self.assertTrue(self.keychain.write(d, doc2))
        again = self.keychain.probe(d)
        self.assertEqual(again['doc']['claudeAiOauth']['accessToken'], 'sk-ant-oat01-y')
        self.assertTrue(self.keychain.delete(d))
        self.assertEqual(self.keychain.probe(d)['state'], 'absent')
        self.assertTrue(self.keychain.delete(d))     # deleting a missing item is fine

    def test_locked_is_distinguished_from_absent(self):
        d = str(self.root / 'acct-01')
        self.keychain.write(d, DOC, account='tester')
        os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
        res = self.keychain.probe(d)
        self.assertEqual(res['state'], 'locked')
        self.assertIsNone(res['doc'])
        self.assertEqual(res['account'], 'tester')
        self.assertGreater(self.keychain.item_mtime(d), 0)
        # an id with NO item is still absent, not locked
        self.assertEqual(self.keychain.probe(d + '-other')['state'], 'absent')

    def test_corrupt_item(self):
        d = str(self.root / 'acct-01')
        svc = self.keychain.service_name(d)
        self.kcdir.mkdir(exist_ok=True)
        (self.kcdir / svc).write_text('not json at all')
        self.assertEqual(self.keychain.probe(d)['state'], 'corrupt')

    def test_kill_switch(self):
        d = str(self.root / 'acct-01')
        self.keychain.write(d, DOC)
        os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '0'
        self.assertFalse(self.keychain.enabled())
        self.assertEqual(self.keychain.probe(d)['state'], 'absent')
        os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'

    # -- audit + report integration --------------------------------------------------
    def _manifest(self):
        (self.root / 'accounts.json').write_text(json.dumps({
            'version': 1, 'server': 'root@203.0.113.1',
            'server_root': '/root/.claude-accounts',
            'server_repo': '/root/claude-multiacc', 'threshold': 90,
            'accounts': [{'id': 'acct-01', 'email': 'kc@test', 'home': 'mac',
                          'added_at': '2026-08-28T00:00:00Z'}]}))

    def test_audit_sees_a_keychain_login(self):
        import audit
        self._manifest()
        d = str(self.root / 'acct-01')
        self.assertEqual(
            audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
                                                 'home': 'mac'}, machine='mac')['state'],
            'missing')
        self.keychain.write(d, DOC)
        row = audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
                                                   'home': 'mac'}, machine='mac')
        self.assertEqual((row['state'], row['store']), ('ok', 'keychain'))
        os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
        row = audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
                                                   'home': 'mac'}, machine='mac')
        self.assertEqual(row['state'], 'locked')
        self.assertEqual(row['label'], 'KEYCHAIN LOCKED')
        os.environ.pop('FAKE_KEYCHAIN_LOCKED', None)

    def test_report_class_and_status(self):
        self._manifest()
        d = str(self.root / 'acct-01')
        self.keychain.write(d, DOC)
        out = subprocess.run(
            [sys.executable, str(REPO / 'lib' / 'report.py'), str(self.root),
             'claude', 'mac', 'list'],
            capture_output=True, text=True, check=True, env=os.environ.copy())
        row = json.loads(out.stdout)['accounts'][0]
        self.assertEqual(row['status'], 'active')
        self.assertEqual(row['credential_class'], 'machine-local')
        self.assertEqual(row['credentials']['oauth_store'], 'keychain')
        self.assertEqual(row['credentials']['keychain'], 'readable')
        env = dict(os.environ, FAKE_KEYCHAIN_LOCKED='1')
        out = subprocess.run(
            [sys.executable, str(REPO / 'lib' / 'report.py'), str(self.root),
             'claude', 'mac', 'list'],
            capture_output=True, text=True, check=True, env=env)
        row = json.loads(out.stdout)['accounts'][0]
        self.assertEqual(row['status'], 'locked')
        self.assertEqual(row['credential_class'], 'machine-local')
        self.assertFalse(row['selectable'])
        self.assertFalse(row['needs_login'])


if __name__ == '__main__':
    unittest.main()
