#!/usr/bin/env node
// codex-acct — switch / rotate the Codex (~/.codex/auth.json) account from the
// local OmniRoute codex pool, ranked by BOTH the 5-hour (session) and weekly quota.
// Commands: whoami | list | use <email|N> | rotate | refresh
import { createDecipheriv, createCipheriv, scryptSync, randomBytes, randomUUID } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, chmodSync, readdirSync, renameSync, statSync } from 'node:fs';
import { homedir, hostname } from 'node:os';
import { execSync, exec as execAsync } from 'node:child_process';

const HOME = homedir();
const POOL = `${HOME}/.codex/accounts/pool.json`;
const AUTH = `${HOME}/.codex/auth.json`;

const isMini = /mini/i.test(hostname());
const OMNI_HOME = isMini ? '/Users/omniroute_svc/.local/share/omniroute-app' : `${HOME}/.local/share/omniroute-app`;
const sudoU = isMini ? 'sudo -u omniroute_svc ' : '';

function jwt(t){ try { return JSON.parse(Buffer.from(String(t).split('.')[1],'base64url').toString()); } catch { return {}; } }
function fmtExp(t){ const e=jwt(t).exp; return e? new Date(e*1000).toISOString().slice(0,16).replace('T',' ') : '?'; }
function readPool(){ if(!existsSync(POOL)){ console.error('No pool. Run: codex-acct refresh'); process.exit(1);} return JSON.parse(readFileSync(POOL,'utf8')); }
function currentEmail(){ try {
  const tk = JSON.parse(readFileSync(AUTH,'utf8'))?.tokens || {};
  const e = jwt(tk.id_token).email || jwt(tk.access_token).email;
  if(e) return e;
  // токен без email (oauth id_token) — сопоставляем по account_id с пулом
  const aid = tk.account_id || (jwt(tk.access_token)['https://api.openai.com/auth']||{}).chatgpt_account_id;
  if(aid && existsSync(POOL)){ const m=JSON.parse(readFileSync(POOL,'utf8')).find(x=>x.tokens?.account_id===aid); if(m) return m.email; }
  return null;
} catch { return null; } }

// quota helpers: null means unknown (no snapshot)
const q5 = a => a.quota_5h, qw = a => a.quota_week;
const both = a => Math.min(q5(a)??-1, qw(a)??-1);             // worst of the two (higher = better)
const sum  = a => (q5(a)??-1) + (qw(a)??-1);
const rankCmp = (a,b) => both(b)-both(a) || sum(b)-sum(a);    // maximise the weaker quota, then the total
const now = () => new Date().toISOString();
const notLimited = a => !a.rate_limited_until || a.rate_limited_until < now();
const hasBoth = a => (q5(a)??0) > 0 && (qw(a)??0) > 0;        // both quotas known-positive

// LIVE quota straight from ChatGPT (OmniRoute snapshots are stale — codex goes direct).
// NOTE: codex refresh_tokens are one-time/rotating and the stored snapshots are spent,
// so we must NOT try to OAuth-refresh (that can revoke the still-valid access_token).
// We classify purely by the access_token against the usage endpoint:
//   status 'ok'    = token valid AND 5h/weekly quota free (usable right now)
//   status 'maxed' = token valid but quota exhausted (usable after reset)
//   status 'dead'  = token revoked/expired/unreachable (needs re-auth)
function liveUsage(at, ws){
  const px = isMini ? '-x http://127.0.0.1:2081 ' : '';
  let out;
  try {
    out = execSync(`curl -s -m 15 ${px}https://chatgpt.com/backend-api/wham/usage -H "Authorization: Bearer ${at}" -H "chatgpt-account-id: ${ws}" -H "User-Agent: Mozilla/5.0"`, {encoding:'utf8', maxBuffer:10*1024*1024});
  } catch { return { status:'dead', reason:'curl' }; }
  let j; try { j = JSON.parse(out); } catch { return { status:'dead', reason:'parse' }; }
  if (j.error || j.status===401 || !j.rate_limit) return { status:'dead', reason:(j.error&&j.error.code)||('http'+(j.status||'?')) };
  const rl = j.rate_limit, p = rl.primary_window||{}, s = rl.secondary_window||{};
  const resetMin = p.reset_after_seconds!=null ? Math.round(p.reset_after_seconds/60) : null;
  const ok = rl.allowed===true && rl.limit_reached!==true;
  return { status: ok?'ok':'maxed', allowed:ok, p5:p.used_percent, pw:s.used_percent, resetMin };
}
// async version (для параллельной проверки всего пула — rotate)
function liveUsageAsync(at, ws){ return new Promise(res=>{
  const px = isMini ? '-x http://127.0.0.1:2081 ' : '';
  execAsync(`curl -s -m 15 ${px}https://chatgpt.com/backend-api/wham/usage -H "Authorization: Bearer ${at}" -H "chatgpt-account-id: ${ws}" -H "User-Agent: Mozilla/5.0"`, {maxBuffer:10*1024*1024}, (e,out)=>{
    if(e) return res({status:'dead',reason:'curl'});
    let j; try{ j=JSON.parse(out); }catch{ return res({status:'dead',reason:'parse'}); }
    if(j.error||j.status===401||!j.rate_limit) return res({status:'dead',reason:(j.error&&j.error.code)||('http'+(j.status||'?'))});
    const rl=j.rate_limit, p=rl.primary_window||{}, s=rl.secondary_window||{};
    const ok=rl.allowed===true && rl.limit_reached!==true;
    res({status:ok?'ok':'maxed',allowed:ok,p5:p.used_percent,pw:s.used_percent,resetMin:p.reset_after_seconds!=null?Math.round(p.reset_after_seconds/60):null});
  });
});}
// проверить массив аккаунтов параллельно (concurrency-pool), вернуть массив статусов
async function checkAll(cands, conc=20){ const out=new Array(cands.length); let i=0;
  await Promise.all(Array.from({length:Math.min(conc,cands.length)},async()=>{ while(i<cands.length){ const idx=i++; out[idx]=await liveUsageAsync(cands[idx].tokens.access_token, cands[idx].tokens.account_id); } }));
  return out;
}
const shuffle = a => { for(let i=a.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]]; } return a; };

function refresh(){
  const DB = `${OMNI_HOME}/data/storage.sqlite`, ENVF = `${OMNI_HOME}/.env`;
  const secret = (isMini ? execSync(`${sudoU}cat ${ENVF}`,{encoding:'utf8'}) : readFileSync(ENVF,'utf8'))
                   .match(/^STORAGE_ENCRYPTION_KEY=(.*)$/m)[1].trim();
  const KEY = scryptSync(secret,'omniroute-field-encryption-v1',32);
  const dec = v => { const [iv,e,t]=v.slice(7).split(':'); const d=createDecipheriv('aes-256-gcm',KEY,Buffer.from(iv,'hex'),{authTagLength:16}); d.setAuthTag(Buffer.from(t,'hex')); return d.update(e,'hex','utf8')+d.final('utf8'); };
  let dbPath = DB;
  if(isMini){ const tmp='/tmp/_codex_pool.sqlite'; execSync(`${sudoU}/usr/bin/sqlite3 ${DB} ".backup '${tmp}'" && sudo chown $(id -u) ${tmp}`); dbPath=tmp; }
  const db = new DatabaseSync(dbPath);
  const rows = db.prepare(`SELECT id,email,access_token,refresh_token,id_token,provider_specific_data,rate_limited_until,"group" FROM provider_connections WHERE provider='codex' AND is_active=1`).all();
  const latest = (win) => { const m={}; try { for(const r of db.prepare(
      `SELECT connection_id, remaining_percentage rp, is_exhausted ex FROM quota_snapshots WHERE provider='codex' AND window_key=?
       AND id IN (SELECT MAX(id) FROM quota_snapshots WHERE provider='codex' AND window_key=? GROUP BY connection_id)`).all(win,win))
      m[r.connection_id]={rp:r.rp, ex:r.ex}; } catch {} return m; };
  const S = latest('session'), W = latest('weekly');
  const pct = (e) => !e ? null : (e.ex ? 0 : Math.round(e.rp));
  const pool = rows.map(r=>{ const p=JSON.parse(r.provider_specific_data||'{}');
    return { email:r.email, workspaceId:p.workspaceId, plan:p.workspacePlanType, group:r.group,
             quota_5h: pct(S[r.id]), quota_week: pct(W[r.id]), rate_limited_until:r.rate_limited_until,
             tokens:{ id_token:dec(r.id_token), access_token:dec(r.access_token), refresh_token:dec(r.refresh_token), account_id:p.workspaceId } }; });
  db.close();
  mkdirSync(`${HOME}/.codex/accounts`,{recursive:true});
  writeFileSync(POOL, JSON.stringify(pool,null,2)); chmodSync(POOL,0o600);
  if(isMini) execSync('rm -f /tmp/_codex_pool.sqlite');
  console.log(`pool refreshed: ${pool.length} accounts (${pool.filter(hasBoth).length} with both quotas) -> ${POOL}`);
}

// ── PRUNE: удалить из OmniRoute codex-аккаунты, которые сервер ChatGPT отвергает по авторизации ──
// killable = HTTP 401 (token_invalidated) / 402 (deactivated_workspace) — НЕ лимит (200) и НЕ транзиентные сбои.
// LIVE-проверка по HTTP-коду (не по liveUsage 'dead', который путает 402 с network-fail).
function probeCode(at, ws){ return new Promise(res=>{
  const px = isMini ? '-x http://127.0.0.1:2081 ' : '';
  execAsync(`curl -s -m 20 ${px}-o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${at}" -H "chatgpt-account-id: ${ws}" -H "User-Agent: Mozilla/5.0" https://chatgpt.com/backend-api/wham/usage`,
    {maxBuffer:10*1024*1024},(e,out)=>res(e?'000':String(out).trim()));
});}
// читает ВСЕ codex-строки из БД OmniRoute (вкл. is_active=0), с расшифрованным access_token
function loadCodexRows(){
  const DB = `${OMNI_HOME}/data/storage.sqlite`, ENVF = `${OMNI_HOME}/.env`;
  const secret = (isMini ? execSync(`${sudoU}cat ${ENVF}`,{encoding:'utf8'}) : readFileSync(ENVF,'utf8')).match(/^STORAGE_ENCRYPTION_KEY=(.*)$/m)[1].trim();
  const KEY = scryptSync(secret,'omniroute-field-encryption-v1',32);
  const dec = v => { const [iv,e,t]=v.slice(7).split(':'); const d=createDecipheriv('aes-256-gcm',KEY,Buffer.from(iv,'hex'),{authTagLength:16}); d.setAuthTag(Buffer.from(t,'hex')); return d.update(e,'hex','utf8')+d.final('utf8'); };
  let dbPath = DB;
  if(isMini){ const tmp='/tmp/_codex_prune.sqlite'; execSync(`${sudoU}/usr/bin/sqlite3 ${DB} ".backup '${tmp}'" && sudo chown $(id -u) ${tmp}`); dbPath=tmp; }
  const db = new DatabaseSync(dbPath);
  const rows = db.prepare("SELECT id,email,access_token,provider_specific_data,is_active FROM provider_connections WHERE provider='codex'").all();
  const out = rows.map(r=>({ id:r.id, email:r.email, is_active:r.is_active, ws:(JSON.parse(r.provider_specific_data||'{}')).workspaceId||'', at:dec(r.access_token) }));
  db.close();
  if(isMini) execSync('rm -f /tmp/_codex_prune.sqlite');
  return out;
}
// удалить строки по id. mac: стоп демона→delete→старт (своя инстанция). mini: ОНЛАЙН (prod демон не трогаем — WAL+busy_timeout, ACID-safe).
function pruneDeleteIds(ids){
  const DB = `${OMNI_HOME}/data/storage.sqlite`;
  const inlist = ids.map(id=>`'${String(id).replace(/'/g,"''")}'`).join(',');
  if(isMini){
    execSync(`${sudoU}/usr/bin/sqlite3 ${DB} "PRAGMA busy_timeout=15000; DELETE FROM provider_connections WHERE provider='codex' AND id IN (${inlist});"`);
  } else {
    const uid=execSync('id -u',{encoding:'utf8'}).trim();
    try{ execSync(`launchctl bootout gui/${uid}/com.cebers.omniroute 2>/dev/null`);}catch{}
    for(let i=0;i<20;i++){ let run=true; try{execSync('pgrep -f omniroute/bin/omniroute.mjs >/dev/null 2>&1');}catch{run=false;} if(!run)break; try{execSync('sleep 0.5');}catch{} }
    const db=new DatabaseSync(DB); db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
    db.prepare(`DELETE FROM provider_connections WHERE provider='codex' AND id IN (${inlist})`).run();
    db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); db.close();
    try{ execSync(`launchctl bootstrap gui/${uid} ${HOME}/Library/LaunchAgents/com.cebers.omniroute.plist`);}catch{}
    for(let i=0;i<20;i++){ try{execSync('pgrep -f omniroute/bin/omniroute.mjs >/dev/null 2>&1'); break;}catch{ try{execSync('sleep 0.5');}catch{} } }
  }
}

// ── INBOX / ingest: подхватить выгруженные файлы из ~/.codex/inbox, проверить live, импортировать валидные ──
const INBOX = `${HOME}/.codex/inbox`;
function getKey(){
  const secret = (isMini ? execSync(`${sudoU}cat ${OMNI_HOME}/.env`,{encoding:'utf8'}) : readFileSync(`${OMNI_HOME}/.env`,'utf8'))
                   .match(/^STORAGE_ENCRYPTION_KEY=(.*)$/m)[1].trim();
  return scryptSync(secret,'omniroute-field-encryption-v1',32);
}
function encField(KEY, text){ const iv=randomBytes(12); const c=createCipheriv('aes-256-gcm',KEY,iv,{authTagLength:16});
  const ct=c.update(String(text),'utf8','hex')+c.final('hex'); return `enc:v1:${iv.toString('hex')}:${ct}:${c.getAuthTag().toString('hex')}`; }
// разобрать файл аккаунта любого формата (top-level / tokens.{} / rt / cpa synthetic)
function extractAcc(o){
  const at=o?.tokens?.access_token||o?.access_token||null; if(!at) return null;
  const rt=o?.tokens?.refresh_token||o?.refresh_token||o?.rt||null;
  const idt=o?.tokens?.id_token||o?.id_token||null;
  const claim=jwt(at)['https://api.openai.com/auth']||{};
  const ws=claim.chatgpt_account_id||o?.account_id||o?.tokens?.account_id||o?.workspaceId||null;
  const email=jwt(idt).email||jwt(at).email||(jwt(at)['https://api.openai.com/profile']||{}).email||o?.email||null;
  return { email, ws, exp:jwt(at).exp, tokens:{ id_token:idt, access_token:at, refresh_token:rt, account_id:ws } };
}
// импорт пачки живых аккаунтов в OmniRoute (macbook): стоп демона → INSERT (клон шаблона) → старт
function ingestImportMac(accs){
  if(!accs.length) return 0;
  const uid=execSync('id -u',{encoding:'utf8'}).trim();
  const DB=`${OMNI_HOME}/data/storage.sqlite`, PLIST=`${HOME}/Library/LaunchAgents/com.cebers.omniroute.plist`;
  try{ execSync(`launchctl bootout gui/${uid}/com.cebers.omniroute 2>/dev/null`);}catch{}
  for(let i=0;i<12;i++){ let run=true; try{execSync('pgrep -f omniroute/bin/omniroute.mjs >/dev/null 2>&1');}catch{run=false;} if(!run)break; try{execSync('sleep 0.5');}catch{} }
  const KEY=getKey(); const db=new DatabaseSync(DB); db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
  const tmpl=db.prepare(`SELECT * FROM provider_connections WHERE provider='codex' AND "group"='codex-team-pool' LIMIT 1`).get();
  const now=new Date().toISOString(); let n=0;
  for(const a of accs){
    if(db.prepare(`SELECT count(*) c FROM provider_connections WHERE provider='codex' AND email=?`).get(a.email).c>0) continue;
    const exp=a.exp?new Date(a.exp*1000).toISOString():null; const sub=jwt(a.tokens.id_token).sub||null;
    const row={...tmpl};
    row.id=randomUUID(); row.name=a.email; row.email=a.email;
    row.access_token=encField(KEY,a.tokens.access_token); row.refresh_token=encField(KEY,a.tokens.refresh_token||''); row.id_token=encField(KEY,a.tokens.id_token||'');
    row.token_expires_at=exp?encField(KEY,exp):tmpl.token_expires_at; row.expires_at=exp;
    row.provider_specific_data=JSON.stringify({workspaceId:a.ws,workspacePlanType:'team',chatgptUserId:sub,email:a.email,importedAt:now,importBatch:'inbox-ingest'});
    row.test_status=null;row.error_code=null;row.last_error=null;row.last_error_at=null;row.last_error_type=null;row.last_error_source=null;
    row.rate_limited_until=null;row.backoff_level=0;row.consecutive_use_count=0;row.last_used_at=null;row.priority=0;
    row.created_at=now;row.updated_at=now;
    const cols=Object.keys(row);
    db.prepare(`INSERT INTO provider_connections (${cols.map(c=>'"'+c+'"').join(',')}) VALUES (${cols.map(()=>'?').join(',')})`).run(...cols.map(c=>row[c]));
    n++;
  }
  db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); db.close();
  try{ execSync(`launchctl bootstrap gui/${uid} ${PLIST}`);}catch{}
  for(let i=0;i<10;i++){ try{execSync('pgrep -f omniroute/bin/omniroute.mjs >/dev/null 2>&1'); break;}catch{ try{execSync('sleep 0.5');}catch{} } }
  return n;
}
function isRunning(){ try { execSync('pgrep -x Codex >/dev/null 2>&1'); return true; } catch { return false; } }
function restartApp(){
  try { execSync(`osascript -e 'quit app "Codex"' 2>/dev/null`); } catch {}
  // wait for the app to actually exit before reopening (avoids open-before-quit race)
  for(let i=0;i<14;i++){ if(!isRunning()) break; try{ execSync('sleep 0.5'); }catch{} }
  if(isRunning()){ try { execSync('pkill -x Codex 2>/dev/null'); } catch {}
    for(let i=0;i<6;i++){ if(!isRunning()) break; try{ execSync('sleep 0.5'); }catch{} } }
  try { execSync('sleep 1; open -a Codex'); } catch {}
  // confirm it came back up
  for(let i=0;i<10;i++){ if(isRunning()) break; try{ execSync('sleep 0.5'); }catch{} }
  console.log(isRunning() ? 'Codex.app restarted ✓' : '(could not relaunch Codex.app — open it manually)');
}
function install(a){
  if(existsSync(AUTH)) copyFileSync(AUTH, `${AUTH}.bak-prev`);
  writeFileSync(AUTH, JSON.stringify({ auth_mode:'chatgpt', OPENAI_API_KEY:null, tokens:a.tokens, last_refresh:now() },null,2)); chmodSync(AUTH,0o600);
  const f = v => v==null?'  ?':String(v).padStart(3);
  console.log(`switched -> ${a.email}\n   5h=${f(q5(a))}%  week=${f(qw(a))}%  access exp ${fmtExp(a.tokens.access_token)}`);
}
const fp = v => v==null ? '  ? ' : (String(v)+'%').padStart(4);

// импорт пачки codex-аккаунтов в OmniRoute (preserved-personal, is_active=1), ОДИН daemon-цикл. mac-only.
// accs: [{email, ws, t(=tokens)}]. UPDATE если email уже есть, иначе INSERT (клон шаблона).
function importCodexAccounts(accs){
  if(!accs.length) return 0;
  const uid=execSync('id -u',{encoding:'utf8'}).trim();
  const DB=`${OMNI_HOME}/data/storage.sqlite`, PLIST=`${HOME}/Library/LaunchAgents/com.cebers.omniroute.plist`;
  try{ execSync(`launchctl bootout gui/${uid}/com.cebers.omniroute 2>/dev/null`);}catch{}
  for(let i=0;i<20;i++){ let run=true; try{execSync('pgrep -f omniroute/bin/omniroute.mjs >/dev/null 2>&1');}catch{run=false;} if(!run)break; try{execSync('sleep 0.5');}catch{} }
  const KEY=getKey(); const db=new DatabaseSync(DB); db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
  const tmpl=db.prepare(`SELECT * FROM provider_connections WHERE provider='codex' LIMIT 1`).get();
  const nowts=new Date().toISOString(); let n=0;
  for(const a of accs){
    const {email,ws,t}=a;
    const exp = jwt(t.access_token).exp ? new Date(jwt(t.access_token).exp*1000).toISOString() : null;
    const sub = jwt(t.id_token).sub || null;
    const psd = JSON.stringify({workspaceId:ws,workspacePlanType:'personal',chatgptUserId:sub,email,importedAt:nowts,importBatch:'folder-capture'});
    // дедуп/UPDATE по account_id (а не email: у dead-строки email мог быть плейсхолдером)
    const existing = db.prepare("SELECT id FROM provider_connections WHERE provider='codex' AND json_extract(provider_specific_data,'$.workspaceId')=?").get(ws)
                   || db.prepare("SELECT id FROM provider_connections WHERE provider='codex' AND email=?").get(email);
    if(existing){
      db.prepare(`UPDATE provider_connections SET email=?,name=?,access_token=?,refresh_token=?,id_token=?,expires_at=?,token_expires_at=?,is_active=1,"group"='preserved-personal',provider_specific_data=?,test_status=NULL,error_code=NULL,last_error=NULL,last_error_at=NULL,rate_limited_until=NULL,backoff_level=0,updated_at=? WHERE id=?`)
        .run(email,email,encField(KEY,t.access_token),encField(KEY,t.refresh_token||''),encField(KEY,t.id_token||''),exp,exp?encField(KEY,exp):null,psd,nowts,existing.id);
    } else {
      if(!tmpl) continue;
      const row={...tmpl};
      row.id=randomUUID(); row.name=email; row.email=email; row.is_active=1; row.group='preserved-personal';
      row.access_token=encField(KEY,t.access_token); row.refresh_token=encField(KEY,t.refresh_token||''); row.id_token=encField(KEY,t.id_token||'');
      row.token_expires_at=exp?encField(KEY,exp):tmpl.token_expires_at; row.expires_at=exp;
      row.provider_specific_data=psd;
      row.test_status=null;row.error_code=null;row.last_error=null;row.last_error_at=null;row.last_error_type=null;row.last_error_source=null;
      row.rate_limited_until=null;row.backoff_level=0;row.consecutive_use_count=0;row.last_used_at=null;row.priority=0;
      row.created_at=nowts;row.updated_at=nowts;
      const cols=Object.keys(row);
      db.prepare(`INSERT INTO provider_connections (${cols.map(c=>'"'+c+'"').join(',')}) VALUES (${cols.map(()=>'?').join(',')})`).run(...cols.map(c=>row[c]));
    }
    n++;
  }
  db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); db.close();
  try{ execSync(`launchctl bootstrap gui/${uid} ${PLIST}`);}catch{}
  for(let i=0;i<20;i++){ try{execSync('pgrep -f omniroute/bin/omniroute.mjs >/dev/null 2>&1'); break;}catch{ try{execSync('sleep 0.5');}catch{} } }
  return n;
}

const [cmd, arg] = process.argv.slice(2);
switch(cmd){
  case 'whoami': {
    const e=currentEmail(); const d=JSON.parse(readFileSync(AUTH,'utf8'));
    const a=(existsSync(POOL)?readPool():[]).find(x=>x.email===e);
    console.log(`current: ${e||'(none)'}  access exp ${fmtExp(d?.tokens?.access_token)}` + (a?`  | 5h=${fp(q5(a))} week=${fp(qw(a))}`:'')); break;
  }
  case 'list': {
    const pool=readPool().sort(rankCmp); const cur=currentEmail();
    console.log('       5h   week   account');
    pool.forEach((a,i)=>{ const m=a.email===cur?'➜':' '; const rl=notLimited(a)?'':' [RL]';
      console.log(`${m}${String(i+1).padStart(3)} ${fp(q5(a))} ${fp(qw(a))}  ${a.email}${rl}`); });
    console.log(`\n${pool.length} accounts (sorted by min(5h,week)). ➜ current = ${cur||'(none)'}`); break;
  }
  case 'use': {
    if(!arg){ console.error('usage: codex-acct use <email|N>'); process.exit(1); }
    const pool=readPool().sort(rankCmp);
    const byNum = /^\d+$/.test(arg) && +arg>=1 && +arg<=pool.length;   // номер в списке — только если в диапазоне (cloud-ip email'ы тоже цифровые)
    const a = byNum ? pool[+arg-1] : pool.find(x=>x.email===arg||x.email.startsWith(arg));
    if(!a){ console.error('not found:',arg); process.exit(1); }
    if(a.tokens?.access_token){ const u=liveUsage(a.tokens.access_token, a.tokens.account_id);
      if(u.status==='dead') console.log(`⚠ warning: this account's token is DEAD (${u.reason}) — Codex will not work, it needs re-auth`);
      else if(u.status==='maxed') console.log(`⚠ note: token valid but 5h quota maxed`+(u.resetMin!=null?` (resets ~${u.resetMin}m)`:'')+` — works after reset`);
      else console.log(`live: использовано ${u.p5}%${u.pw!=null?`, неделя ${u.pw}%`:' (free, одно окно)'} — usable now`); }
    install(a); restartApp(); break;
  }
  case 'rotate': {
    const pool=readPool(); const cur=currentEmail();
    // Качество аккаунта по ЖИВОЙ проверке:
    //   3 = свободны обе квоты (работает прямо сейчас)
    //   2 = заблокирован только 5h, НЕДЕЛЬНАЯ ЕСТЬ (заработает после сброса 5h ~часы)
    //   1 = недельная выжата (сброс через дни — плохой выбор)
    //   0 = мёртвый токен
    const qual=u=>{ if(!u||u.status==='dead')return 0; if(u.status==='ok')return 3; return (u.pw??0)>=100?1:2; };
    const rs=v=>v==null?'?':`~${v}м (~${(v/60).toFixed(1)}ч)`;
    const why=u=> u.status==='ok'?(u.pw==null?`квота свободна (использовано ${u.p5}%, free — одно окно)`:`свободны обе (5h ${u.p5}% / неделя ${u.pw}% использовано)`)
                : (u.pw??0)>=100?`недельная выжата`
                : `сейчас 5h в потолке (сброс ${rs(u.resetMin)}), недельная ещё есть (использовано ${u.pw}%)`;
    // кандидаты: ЛЮБОЙ аккаунт пула (pool.json = только codex, is_active=1), не текущий, не RL, с токеном.
    // (раньше фильтровали по group team-pool/preserved-personal — отсекало аккаунты с group=null, напр. free cebersdev.)
    let cand=shuffle(pool.filter(a=>a.email!==cur && notLimited(a) && a.tokens?.access_token));
    // ВЕСЬ пул проверяем параллельно (живой может быть единственным среди сотен maxed —
    // случайная подвыборка его не находила). conc-pool, ~секунды.
    process.stdout.write(`проверяю live (${cand.length})… `);
    const us=await checkAll(cand, 20);
    const found=cand.map((c,i)=>({c,u:us[i],q:qual(us[i])})).filter(x=>x.u.status!=='dead');
    const checked=cand.length, deadN=us.filter(u=>u.status==='dead').length;
    process.stdout.write(`готово (живых ${found.length}, мёртвых ${deadN})\n`);
    // лучший: по качеству, затем тай-брейк (ok→больше всего обеих; soon→скорее сброс; week→больше недельной)
    const score=x=> x.q===3 ? Math.max(x.u.p5??0,x.u.pw??0) : x.q===2 ? (x.u.resetMin??9999) : (x.u.pw??100);
    found.sort((a,b)=> b.q-a.q || score(a)-score(b));
    const best=found[0];
    // оценка текущего аккаунта
    const curA=pool.find(x=>x.email===cur);
    const cu=curA?liveUsage(curA.tokens.access_token, curA.tokens.account_id):{status:'dead'};
    const cq=qual(cu);
    // переключаемся, только если best СТРОГО лучше по качеству (или при равном ok — заметно больше остатка)
    let switchIt=false;
    if(best && best.q>cq) switchIt=true;
    else if(best && best.q===3 && cq===3 && (Math.max(cu.p5??0,cu.pw??0)-score(best))>=15) switchIt=true;
    if(switchIt){
      install({...best.c,_live:best.u});
      console.log(`   причина: ${why(best.u)}`);
      if(best.q<3) console.log(`   (сразу работать не будет — ждём сброс 5h; но недельный запас есть, это лучше прежнего аккаунта, у которого ${cq===1?'выжата недельная (сброс через дни)':cq===0?'мёртвый токен':'хуже'})`);
      restartApp(); break;
    }
    // остаёмся
    if(cq===3) console.log(`Текущий (${cur}) свободен по обеим квотам — остаюсь.`);
    else if(cq===2) console.log(`Текущий (${cur}) ждёт сброса 5h (${rs(cu.resetMin)}), недельная есть — лучших вариантов нет, остаюсь.`);
    else if(!best) console.error(`Нет живых аккаунтов для переключения (проверено ${checked}, мёртвых ${deadN}).`);
    else console.log(`Текущий (${cur}) ограничен (${cq===1?'недельная выжата':'мёртвый токен'}), но и кандидаты не лучше — остаюсь.`);
    // подсказка: сейчас рабочих нет вообще
    if((best?best.q:0)<3 && cq<3){ const soonReset=found.filter(x=>x.q===2).map(x=>x.u.resetMin).filter(v=>v!=null).sort((a,b)=>a-b)[0];
      if(soonReset!=null) console.log(`ℹ рабочих аккаунтов сейчас нет; ближайший освободится через ${rs(soonReset)}.`); }
    process.exit(3);
  }
  case 'usage': {
    const a=readPool().find(x=>x.email===currentEmail());
    if(!a){ console.error('current account not in pool'); process.exit(1); }
    const u=liveUsage(a.tokens.access_token, a.tokens.account_id);
    if(u.status==='dead'){ console.log(`${a.email}\n  LIVE: token DEAD (${u.reason}) — this account needs re-auth`); break; }
    if(u.pw==null) console.log(`${a.email}\n  LIVE: использовано ${u.p5}% (free — одно окно)  | allowed=${u.allowed}` + (u.resetMin!=null?`  | сброс ~${Math.round(u.resetMin/1440)}д`:''));
    else console.log(`${a.email}\n  LIVE: 5h used ${u.p5}%  | weekly used ${u.pw}%  | allowed=${u.allowed}` + (u.resetMin!=null?`  | 5h resets in ~${u.resetMin}m`:'')); break;
  }
  case 'scan': {
    const pool=readPool(); const team=pool.filter(a=>a.group==='codex-team-pool'&&a.tokens?.access_token);
    let ok=0,maxed=0,dead=0; const ws={}; let minReset=null;
    process.stdout.write(`scanning ${team.length}`);
    for(const a of team){ process.stdout.write('.'); const u=liveUsage(a.tokens.access_token,a.tokens.account_id);
      const w=(a.tokens.account_id||'?').slice(0,8); ws[w]=ws[w]||{ok:0,maxed:0,dead:0};
      if(u.status==='ok'){ok++;ws[w].ok++;} else if(u.status==='maxed'){maxed++;ws[w].maxed++; if(u.resetMin!=null&&(minReset==null||u.resetMin<minReset))minReset=u.resetMin;} else {dead++;ws[w].dead++;} }
    process.stdout.write('\n');
    console.log(`team ${team.length}: usable-now=${ok}  quota-maxed=${maxed}  dead-token=${dead}`);
    for(const [w,c] of Object.entries(ws)) console.log(`  ws ${w}: usable=${c.ok} maxed=${c.maxed} dead=${c.dead}`);
    if(ok===0 && minReset!=null) console.log(`all responding accounts share a maxed 5h budget — earliest reset ~${minReset}m (~${(minReset/60).toFixed(1)}h)`);
    break;
  }
  case 'ingest': {
    if(isMini){ console.log('ingest пока только на macbook (на mini рестарт демона требует sudo)'); break; }
    mkdirSync(INBOX,{recursive:true}); mkdirSync(`${INBOX}/dead`,{recursive:true}); mkdirSync(`${INBOX}/imported`,{recursive:true});
    const files=readdirSync(INBOX).filter(f=>f.endsWith('.json'));
    if(!files.length){ if(arg!=='quiet') console.log(`inbox пуст (${INBOX})`); break; }
    process.stdout.write(`inbox: ${files.length} файл(ов), проверяю live`);
    const alive=[]; let dead=0;
    for(const f of files){ process.stdout.write('.');
      let o; try{ o=JSON.parse(readFileSync(`${INBOX}/${f}`,'utf8')); }catch{ try{renameSync(`${INBOX}/${f}`,`${INBOX}/dead/${f}`);}catch{} dead++; continue; }
      const a=extractAcc(o);
      if(!a||!a.tokens.access_token||!a.ws){ try{renameSync(`${INBOX}/${f}`,`${INBOX}/dead/${f}`);}catch{} dead++; continue; }
      const u=liveUsage(a.tokens.access_token, a.tokens.account_id);
      if(u.status==='dead'){ try{renameSync(`${INBOX}/${f}`,`${INBOX}/dead/${f}`);}catch{} dead++; continue; }
      alive.push({a,f,u});
    }
    process.stdout.write('\n');
    const imported = alive.length ? ingestImportMac(alive.map(x=>x.a)) : 0;
    for(const x of alive){ try{ renameSync(`${INBOX}/${x.f}`,`${INBOX}/imported/${x.f}`); }catch{} }
    if(imported>0){ try{ refresh(); }catch(e){ console.log('refresh:',e.message); } }
    console.log(`ingest: импортировано ${imported}, живых ${alive.length} (дубли пропущены), мёртвых ${dead}. dead→${INBOX}/dead, imported→${INBOX}/imported`);
    break;
  }
  case 'prune': {
    const apply = arg==='--apply' || arg==='apply';
    const rows = loadCodexRows();
    process.stdout.write(`probing ${rows.length} codex accounts (live)`);
    const conc=20; let i=0; const codes=new Array(rows.length);
    await Promise.all(Array.from({length:Math.min(conc,rows.length)},async()=>{ while(i<rows.length){ const idx=i++; codes[idx]=await probeCode(rows[idx].at, rows[idx].ws); process.stdout.write('.'); }}));
    process.stdout.write('\n');
    const by={}; rows.forEach((r,idx)=>{ by[codes[idx]]=(by[codes[idx]]||0)+1; });
    const kill = rows.filter((r,idx)=>codes[idx]==='401'||codes[idx]==='402');
    const keep = rows.filter((r,idx)=>codes[idx]==='200').map(r=>r.email);
    const other = rows.filter((r,idx)=>!['200','401','402'].includes(codes[idx]));
    console.log('by HTTP code:', JSON.stringify(by));
    console.log(`KILL  (401 token_invalidated / 402 deactivated_workspace): ${kill.length}`);
    console.log(`KEEP  (200, alive): ${keep.length}`, keep);
    if(other.length) console.log(`OTHER (kept — transient/non-auth, NOT deleted): ${other.length}`, other.map(r=>r.email).slice(0,8));
    if(!kill.length){ console.log('nothing to prune.'); break; }
    if(!apply){ console.log(`\nDRY-RUN — ничего не удалено. Для удаления этих ${kill.length}: codex-acct prune --apply`); break; }
    const DB=`${OMNI_HOME}/data/storage.sqlite`;
    const ts=new Date().toISOString().replace(/[-:]/g,'').replace('T','-').slice(0,15);
    const bak=`${DB}.bak-prune-${ts}`;
    execSync(`${sudoU}/usr/bin/sqlite3 ${DB} ".backup '${bak}'"`);
    console.log('DB backup ->', bak);
    pruneDeleteIds(kill.map(r=>r.id));
    console.log(`deleted ${kill.length} dead codex accounts${isMini?' (online, prod daemon untouched)':' (daemon stopped+restarted)'}.`);
    try{ refresh(); }catch(e){ console.log('refresh skipped:',e.message); }
    break;
  }
  case 'capture': {
    // импорт в пул codex-аккаунтов из ИСТОЧНИКА (preserved-personal, is_active=1, чтобы rotate их видел).
    //   codex-acct capture                 → ~/.codex/auth.json (после `codex login`)
    //   codex-acct capture /path/auth.json → один файл
    //   codex-acct capture /path/to/dir    → все *.json в папке (импорт + сравнение/дедуп по account_id)
    // live-проверка по access_token (HTTP 200). ВАЖНО: проверяется только access; «обновляемость» (refresh_token)
    // снаружи не видна — экспортный токен может давать в GUI «could not be refreshed», если исходную сессию прокрутили.
    if(isMini){ console.log('capture только на macbook (browser login там)'); break; }
    let srcs;
    if(arg){
      // путь задан явно — НЕ откатываемся молча на auth.json: если не найден, громко падаем.
      if(!existsSync(arg)){ console.error(`✗ источник не найден: ${arg}`); process.exit(1); }
      srcs = statSync(arg).isDirectory()
        ? readdirSync(arg).filter(f=>f.endsWith('.json')).map(f=>`${arg}/${f}`).sort()
        : [arg];
      console.log(`источник: ${arg}${statSync(arg).isDirectory()?` (папка, ${srcs.length} *.json)`:''}`);
    } else srcs = [AUTH];
    if(!srcs.length){ console.log(`нет .json в источнике: ${arg||AUTH}`); break; }
    const pool = existsSync(POOL)?JSON.parse(readFileSync(POOL,'utf8')):[];
    const inPool = new Set(pool.map(a=>a.tokens?.account_id).filter(Boolean));  // что уже в пуле (для NEW vs обновление)
    const seen = new Set();                                                     // дедуп в рамках одной папки
    const toImport=[]; const rep=[];
    for(const src of srcs){
      const fn=src.split('/').pop();
      let d; try{ d=JSON.parse(readFileSync(src,'utf8')); }catch{ rep.push(['—','bad-json',fn]); continue; }
      const t=d?.tokens;
      if(!t?.access_token){ rep.push(['—','no-token',fn]); continue; }
      const ws = t.account_id || (jwt(t.access_token)['https://api.openai.com/auth']||{}).chatgpt_account_id || '';
      const out = execSync(`curl -s -m 20 -w '\\n%{http_code}' -H "Authorization: Bearer ${t.access_token}" -H "chatgpt-account-id: ${ws}" -H "User-Agent: Mozilla/5.0" https://chatgpt.com/backend-api/wham/usage`,{encoding:'utf8'});
      const ul=out.trim().split('\n'); const code=ul.pop().trim(); let b={}; try{b=JSON.parse(ul.join('\n'));}catch{}
      const auth0=(jwt(t.access_token)['https://api.openai.com/auth']||{});
      const email = b.email || jwt(t.id_token).email || jwt(t.access_token).email || auth0.chatgpt_user_id || ('codex-'+String(ws).slice(0,8));
      const used = (b.rate_limit && b.rate_limit.primary_window && b.rate_limit.primary_window.used_percent);
      if(code!=='200'){ rep.push([email, `dead ${code}/${(b.error&&b.error.code)||'?'}`, fn]); continue; }
      if(seen.has(ws)){ rep.push([email, `повтор в папке — пропуск`, fn]); continue; }
      seen.add(ws);
      // живой → импортируем ВСЕГДА (UPDATE свежим токеном, если уже в пуле; иначе INSERT)
      toImport.push({email,ws,t});
      rep.push([email, inPool.has(ws)?`обновлён свежим токеном (used ${used}%)`:`НОВЫЙ (used ${used}%)`, fn]);
    }
    const n = importCodexAccounts(toImport);
    if(n>0){ try{ refresh(); }catch(e){ console.log('refresh skipped:',e.message); } }
    const news=rep.filter(r=>r[1].startsWith('НОВЫЙ')).length, upd=rep.filter(r=>r[1].startsWith('обновлён')).length, dead=rep.filter(r=>/^dead/.test(r[1])).length;
    console.log(`\nфайлов: ${srcs.length} | новых: ${news} | обновлено: ${upd} | мёртвых: ${dead} (записано в пул: ${n})`);
    for(const [em,st,fn] of rep) console.log(`  ${st.startsWith('НОВЫЙ')?'＋':st.startsWith('обновлён')?'↻':'✗'} ${em}  [${st}]  (${fn})`);
    if(dead) console.log('ℹ мёртвые = токен отозван/без доступа. Надёжный способ: codex login на этой машине → codex-acct capture');
    break;
  }
  case 'restart': restartApp(); break;
  case 'refresh': refresh(); break;
  default:
    console.log('codex-acct — switch/rotate Codex account by 5h + weekly quota\n'+
      '  whoami         current account + its (snapshot) 5h/weekly quota\n'+
      '  usage          LIVE quota of the current account (straight from ChatGPT)\n'+
      '  scan           classify whole pool LIVE: usable-now / quota-maxed / dead-token, by workspace\n'+
      '  list           accounts by snapshot min(5h,weekly)  (➜ current, [RL])  — snapshot is approximate\n'+
      '  use <email|N>  switch to a specific account\n'+
      '  rotate         switch to a usable account (LIVE-verified); never strands on a dead token; falls back to personal\n'+
      '  ingest         подхватить файлы из ~/.codex/inbox: live-проверка → импорт валидных в пул (mac)\n'+
      '  prune [--apply] удалить из пула мёртвые по авторизации (HTTP 401/402); без --apply — dry-run\n'+
      '  capture        захватить текущий (после `codex login`) аккаунт в пул как preserved-personal (mac)\n'+
      '  restart        quit & relaunch Codex.app so it re-reads ~/.codex/auth.json\n'+
      '  refresh        rebuild pool from OmniRoute (fresh tokens; snapshot quotas)');
}
