/**
 * commands/tokens.mjs — `apt-tools tokens` family (C11).
 *
 * Per-task token telemetry. Distinct from `token-budget` (C29):
 *   - `token-budget` = forward-looking estimate / cap (planner heuristic).
 *   - `tokens`       = retrospective actuals from session transcripts.
 *
 * Persistence:
 *   - .aperant/tasks/{id}/tokens.json — full entry list (per-session).
 *   - state.active_tasks[id].tokens   — flat rolled-up summary (downstream).
 *
 * Subcommands:
 *   record         — append one entry from a Claude Code transcript or manual --input/--output.
 *   tally          — emit a TaskCostSummary computed via CostTracker.
 *   compare        — emit { aperant, baseline, ratio } (baseline JSON optional; C23 hook).
 *   list           — scan all .aperant/tasks/{*}/tokens.json and emit a flat sorted list.
 *   phase-summary  — join tokens.json × task.lifecycle.* events by phase
 *                    (roundtable 2026-05-14 R6 — phase-boundary telemetry).
 *
 * All writes go through atomicWriteJson + withFileLock (R3), state mutations
 * through createCoordinationStore.updateState (R8), and a lifecycle event is
 * appended via store.appendEvent on successful record (R10).
 */
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
// Use the .mjs runtime mirrors so the CLI works against uncompiled source.
// The .ts twins (pricing.ts / tracker.ts / transcript.ts) remain the
// type-checked library surface; both versions hold identical math/algorithm.
import { calculateCost } from '../../cost/pricing.mjs';
import { CostTracker } from '../../cost/tracker.mjs';
import { parseClaudeCodeTranscript } from '../../cost/transcript.mjs';
import { readTaskEventsOverDays } from '../coordination/event-log.mjs';
import { createCoordinationStore } from '../coordination/store.mjs';
import { withFileLock } from '../util/fs-lock.mjs';
import { atomicWriteJson } from '../util/io.mjs';
import { err, ok, UsageError } from '../util/result.mjs';
/**
 * Sentinel error class for helper-level usage failures inside cmdTokens.
 */
class TokensUsageError extends UsageError {
    constructor(message) {
        super(message);
        this.name = 'TokensUsageError';
    }
}
const DEFAULT_MODEL = 'claude-opus-4-6';
const DEFAULT_PROVIDER = 'anthropic';
function tokensFilePath(targetDir, taskId) {
    return join(resolve(targetDir), '.aperant', 'tasks', taskId, 'tokens.json');
}
function readTokensFile(path) {
    if (!existsSync(path))
        return null;
    try {
        return JSON.parse(readFileSync(path, 'utf-8'));
    }
    catch {
        return null;
    }
}
function nowIso() {
    return new Date().toISOString();
}
/**
 * Convert the on-disk JSON entry shape (snake_case + cost_usd) into the
 * canonical CostEntry shape used by CostTracker (camelCase + costUsd).
 *
 * R6 (roundtable 2026-05-14): `phase` is an optional per-entry tag indicating
 * which pipeline phase (planning | executing | verifying | reviewing) the
 * session belonged to. Carried through to enable `tokens phase-summary` to
 * group by phase. Older entries lacking `phase` keep working — the field is
 * additive and downstream consumers tolerate `undefined`.
 */
function entryToCostEntry(taskId, entry) {
    return {
        taskId,
        sessionId: entry.session_id,
        providerId: entry.provider_id,
        modelId: entry.model_id,
        inputTokens: entry.input_tokens || 0,
        outputTokens: entry.output_tokens || 0,
        thinkingTokens: entry.thinking_tokens || 0,
        cacheReadTokens: entry.cache_read_tokens || 0,
        cacheCreationTokens: entry.cache_creation_tokens || 0,
        costUsd: typeof entry.cost_usd === 'number' ? entry.cost_usd : 0,
        timestamp: entry.timestamp,
        durationMs: entry.duration_ms || 0,
        phase: entry.phase || null,
    };
}
function rollupSummary(entries) {
    let totalInput = 0;
    let totalOutput = 0;
    let totalCost = 0;
    for (const e of entries) {
        totalInput += e.input_tokens || 0;
        totalOutput += e.output_tokens || 0;
        totalCost += typeof e.cost_usd === 'number' ? e.cost_usd : 0;
    }
    return {
        total_input: totalInput,
        total_output: totalOutput,
        total: totalInput + totalOutput,
        cost_usd: Number(totalCost.toFixed(6)),
        session_count: entries.length,
        last_updated: nowIso(),
    };
}
function applyMirrorToTask(taskRecord, summary) {
    if (!taskRecord || typeof taskRecord !== 'object')
        return;
    taskRecord.tokens = summary;
}
function updateStateMirror(targetDir, taskId, summary) {
    const store = createCoordinationStore(targetDir);
    store.updateState((state) => {
        if (!state || typeof state !== 'object')
            return undefined;
        // Active task path
        if (state.active_tasks && state.active_tasks[taskId]) {
            applyMirrorToTask(state.active_tasks[taskId], summary);
            state.last_activity = nowIso();
            return state;
        }
        // Completed task path — find by id and update in place.
        if (Array.isArray(state.completed_tasks)) {
            for (const t of state.completed_tasks) {
                if (t && t.id === taskId) {
                    applyMirrorToTask(t, summary);
                    state.last_activity = nowIso();
                    return state;
                }
            }
        }
        // Task not in state — best-effort no-op (record still wrote tokens.json).
        return undefined;
    });
    return store;
}
function flagOf(args, name) {
    const i = args.indexOf(name);
    return i >= 0 ? args[i + 1] : null;
}
/**
 * record — append one or more entries to tokens.json.
 *
 * Two modes:
 *   --transcript <path>: parse Claude Code JSONL and emit one entry per session
 *   --input N --output N: manual single-entry mode (fallback for non-CC CLIs)
 */
function cmdRecord(targetDir, args) {
    const taskId = flagOf(args, '--task-id');
    if (!taskId)
        throw new TokensUsageError('--task-id is required for tokens record');
    const transcript = flagOf(args, '--transcript');
    const inputStr = flagOf(args, '--input');
    const outputStr = flagOf(args, '--output');
    const sessionIdFlag = flagOf(args, '--session-id');
    const agent = flagOf(args, '--agent') || 'unknown';
    // `--model` is only applied if explicitly passed; otherwise the
    // transcript-provided model wins so per-session entries keep their
    // real model id (used by pricing math). Manual mode falls back to
    // DEFAULT_MODEL because there's no transcript to source it from.
    const modelOverride = flagOf(args, '--model');
    const providerId = flagOf(args, '--provider') || DEFAULT_PROVIDER;
    // R6 (roundtable 2026-05-14): optional --phase tags this batch of entries
    // with the lifecycle phase they belong to. When omitted, phase-summary
    // falls back to inferring phase from lifecycle event timestamps.
    //
    // SEC-001 (PR #115 review): whitelist accepted values to keep parity with
    // --track (which also enforces a four-value allowlist). Unknown values
    // fail closed rather than persisting arbitrary strings into tokens.json.
    const phaseFlag = flagOf(args, '--phase');
    const VALID_PHASES = new Set(['planning', 'executing', 'verifying', 'reviewing']);
    if (phaseFlag !== null && !VALID_PHASES.has(phaseFlag)) {
        throw new TokensUsageError(`--phase must be one of: planning, executing, verifying, reviewing (got: ${phaseFlag})`);
    }
    if (!transcript && (inputStr === null || outputStr === null)) {
        throw new TokensUsageError('Provide --transcript <path> OR both --input N --output N');
    }
    const newEntries = [];
    if (transcript) {
        const parsed = parseClaudeCodeTranscript(transcript);
        if (parsed.length === 0) {
            throw new TokensUsageError(`No assistant turns with usage found in transcript: ${transcript}`);
        }
        const ts = nowIso();
        for (const p of parsed) {
            // Explicit --model overrides the transcript value; otherwise the
            // transcript-reported model wins, falling back to DEFAULT_MODEL
            // only if neither is present.
            const usedModel = modelOverride || p.modelId || DEFAULT_MODEL;
            const { costUsd } = calculateCost(p.inputTokens, p.outputTokens, usedModel);
            newEntries.push({
                session_id: sessionIdFlag || p.sessionId,
                agent,
                provider_id: providerId,
                model_id: usedModel,
                input_tokens: p.inputTokens,
                output_tokens: p.outputTokens,
                cache_read_tokens: p.cacheReadTokens || 0,
                cache_creation_tokens: p.cacheCreationTokens || 0,
                thinking_tokens: p.thinkingTokens || 0,
                duration_ms: p.durationMs || 0,
                cost_usd: costUsd,
                timestamp: p.timestamp || ts,
                source: 'transcript',
                transcript_path: transcript,
                ...(phaseFlag ? { phase: phaseFlag } : {}),
            });
        }
    }
    else {
        const inputTokens = parseInt(inputStr, 10);
        const outputTokens = parseInt(outputStr, 10);
        if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens)) {
            throw new TokensUsageError('--input and --output must be integers');
        }
        if (inputTokens < 0 || outputTokens < 0) {
            throw new TokensUsageError('--input and --output must be non-negative');
        }
        const manualModel = modelOverride || DEFAULT_MODEL;
        const { costUsd } = calculateCost(inputTokens, outputTokens, manualModel);
        newEntries.push({
            session_id: sessionIdFlag || `manual-${Date.now()}`,
            agent,
            provider_id: providerId,
            model_id: manualModel,
            input_tokens: inputTokens,
            output_tokens: outputTokens,
            cache_read_tokens: 0,
            cache_creation_tokens: 0,
            thinking_tokens: 0,
            duration_ms: 0,
            cost_usd: costUsd,
            timestamp: nowIso(),
            source: 'manual',
            ...(phaseFlag ? { phase: phaseFlag } : {}),
        });
    }
    const filePath = tokensFilePath(targetDir, taskId);
    mkdirSync(dirname(filePath), { recursive: true });
    let finalDoc;
    withFileLock(filePath, () => {
        const existing = readTokensFile(filePath) || {
            task_id: taskId,
            scope: null,
            entries: [],
        };
        // Hydrate scope from state if missing — best-effort.
        if (!existing.scope) {
            try {
                const store = createCoordinationStore(targetDir);
                const state = store.readState();
                const t = state?.active_tasks?.[taskId];
                if (t?.scope)
                    existing.scope = t.scope;
                else if (Array.isArray(state?.completed_tasks)) {
                    const c = state.completed_tasks.find((x) => x?.id === taskId);
                    if (c?.scope)
                        existing.scope = c.scope;
                }
            }
            catch {
                /* best-effort */
            }
        }
        existing.entries = Array.isArray(existing.entries) ? existing.entries : [];
        existing.entries.push(...newEntries);
        existing.updated_at = nowIso();
        atomicWriteJson(filePath, existing);
        finalDoc = existing;
    });
    const summary = rollupSummary(finalDoc.entries);
    const store = updateStateMirror(targetDir, taskId, summary);
    // R10 lifecycle event — best-effort, never blocks.
    try {
        store.appendEvent({
            op: 'task.tokens.recorded',
            task: taskId,
            data: {
                session_count: summary.session_count,
                total: summary.total,
                cost_usd: summary.cost_usd,
                source: transcript ? 'transcript' : 'manual',
                added_entries: newEntries.length,
            },
        });
    }
    catch {
        /* best-effort */
    }
    return ok({
        status: 'ok',
        command: 'tokens',
        action: 'record',
        task_id: taskId,
        added_entries: newEntries.length,
        entries_total: finalDoc.entries.length,
        summary,
        tokens_path: filePath,
    });
}
/**
 * tally — read tokens.json and emit a TaskCostSummary computed via CostTracker.
 */
function cmdTally(targetDir, args) {
    const taskId = flagOf(args, '--task-id');
    if (!taskId)
        throw new TokensUsageError('--task-id is required for tokens tally');
    const filePath = tokensFilePath(targetDir, taskId);
    const doc = readTokensFile(filePath);
    if (!doc) {
        return ok({
            status: 'ok',
            command: 'tokens',
            action: 'tally',
            task_id: taskId,
            exists: false,
            summary: null,
        });
    }
    const costEntries = (doc.entries || []).map((e) => entryToCostEntry(taskId, e));
    const tracker = CostTracker.fromJSON(costEntries);
    const summary = tracker.summarize(taskId);
    return ok({
        status: 'ok',
        command: 'tokens',
        action: 'tally',
        task_id: taskId,
        exists: true,
        scope: doc.scope || null,
        summary,
    });
}
/**
 * compare — emit { aperant, baseline, ratio }; baseline is optional.
 */
function cmdCompare(targetDir, args) {
    const taskId = flagOf(args, '--task-id');
    if (!taskId)
        throw new TokensUsageError('--task-id is required for tokens compare');
    const baselinePath = flagOf(args, '--baseline');
    const filePath = tokensFilePath(targetDir, taskId);
    const doc = readTokensFile(filePath);
    let aperantSummary = null;
    if (doc) {
        const costEntries = (doc.entries || []).map((e) => entryToCostEntry(taskId, e));
        aperantSummary = CostTracker.fromJSON(costEntries).summarize(taskId);
    }
    let baseline = null;
    if (baselinePath) {
        if (!existsSync(baselinePath))
            throw new TokensUsageError(`baseline file not found: ${baselinePath}`);
        try {
            baseline = JSON.parse(readFileSync(baselinePath, 'utf-8'));
        }
        catch (e) {
            throw new TokensUsageError(`failed to parse baseline JSON: ${e.message}`);
        }
    }
    let ratio = null;
    if (aperantSummary && baseline && typeof baseline.totalCostUsd === 'number') {
        const a = aperantSummary.totalCostUsd;
        const b = baseline.totalCostUsd;
        ratio = b > 0 ? Number((a / b).toFixed(4)) : null;
    }
    return ok({
        status: 'ok',
        command: 'tokens',
        action: 'compare',
        task_id: taskId,
        aperant: aperantSummary,
        baseline,
        ratio,
    });
}
/**
 * list — scan .aperant/tasks/{*}/tokens.json and emit a flat sorted list.
 */
function cmdList(targetDir, args) {
    const scopeFilter = flagOf(args, '--scope');
    const sinceFlag = flagOf(args, '--since');
    const sinceTs = sinceFlag ? Date.parse(sinceFlag) : null;
    const tasksDir = join(resolve(targetDir), '.aperant', 'tasks');
    const rows = [];
    if (existsSync(tasksDir)) {
        for (const taskId of readdirSync(tasksDir)) {
            const tokensPath = join(tasksDir, taskId, 'tokens.json');
            if (!existsSync(tokensPath))
                continue;
            let doc;
            try {
                doc = JSON.parse(readFileSync(tokensPath, 'utf-8'));
            }
            catch {
                continue;
            }
            if (!doc || !Array.isArray(doc.entries))
                continue;
            if (scopeFilter && doc.scope !== scopeFilter)
                continue;
            const summary = rollupSummary(doc.entries);
            // Restore a stable last_updated from the file's updated_at (rollup
            // recomputes a fresh now-stamp; for list output we want the file's
            // truth so sorting is deterministic across calls).
            const lastUpdated = doc.updated_at || summary.last_updated;
            if (sinceTs && Date.parse(lastUpdated) < sinceTs)
                continue;
            rows.push({
                task_id: doc.task_id || taskId,
                scope: doc.scope || null,
                total: summary.total,
                cost_usd: summary.cost_usd,
                session_count: summary.session_count,
                last_updated: lastUpdated,
                tokens_path: tokensPath,
            });
        }
    }
    rows.sort((a, b) => {
        const ax = Date.parse(a.last_updated) || 0;
        const bx = Date.parse(b.last_updated) || 0;
        return bx - ax;
    });
    return ok({
        status: 'ok',
        command: 'tokens',
        action: 'list',
        count: rows.length,
        entries: rows,
    });
}
/**
 * Read lifecycle events for a task across today + yesterday (UTC).
 * Returns events sorted ascending by `t`. Best-effort: missing event log
 * directories return an empty array — phase-summary degrades gracefully
 * when telemetry isn't enabled.
 */
function readTaskLifecycleEvents(targetDir, taskId) {
    // Delegates to the shared two-day window helper in event-log.mjs so
    // tokens.mjs and the lifecycle-event helpers cannot drift on the window
    // strategy (PR #115 review findings QUA-001 + PRF-001).
    const events = readTaskEventsOverDays(targetDir, taskId, 'task.lifecycle.');
    events.sort((a, b) => Date.parse(a.t) - Date.parse(b.t));
    return events;
}
/**
 * Infer phase for a token entry by finding the most recent lifecycle event
 * whose timestamp <= entry timestamp. Returns null when no lifecycle event
 * precedes the entry (e.g. entries recorded before any lifecycle transition).
 */
function inferPhaseForEntry(entryTime, lifecycleEvents) {
    const entryMs = Date.parse(entryTime);
    if (!Number.isFinite(entryMs))
        return null;
    let match = null;
    for (const evt of lifecycleEvents) {
        const evtMs = Date.parse(evt.t);
        // LOG-002 (PR #115 review): a malformed evt.t parses to NaN. `NaN <=
        // entryMs` is false, which would trip an `else break` and drop all
        // subsequent valid events. Skip NaN entries instead so a single bad
        // row can't poison phase inference.
        if (!Number.isFinite(evtMs))
            continue;
        if (evtMs <= entryMs)
            match = evt;
        else
            break;
    }
    return match?.data?.lifecycle_phase ?? null;
}
/**
 * phase-summary — join tokens.json × task.lifecycle.* events by task_id +
 * phase. Roundtable 2026-05-14 R6 — provides the A/B baseline needed to
 * evaluate sequential vs sub-agents mode without per-call profiling.
 *
 * Each row: { phase, duration_ms, input_tokens, output_tokens, cost_usd, session_count }.
 * Duration is summed from lifecycle event `elapsed_ms`. Tokens are summed
 * from entries whose `phase` is set (or inferred from event timestamps when
 * the entry has no `phase` tag).
 */
function cmdPhaseSummary(targetDir, args) {
    const taskId = flagOf(args, '--task-id');
    if (!taskId)
        throw new TokensUsageError('--task-id is required for tokens phase-summary');
    const tokensPath = tokensFilePath(targetDir, taskId);
    const doc = readTokensFile(tokensPath);
    const entries = doc?.entries || [];
    const lifecycleEvents = readTaskLifecycleEvents(targetDir, taskId);
    // Per-phase accumulator.
    const phases = new Map();
    function bump(phase, patch) {
        if (!phase)
            return;
        if (!phases.has(phase)) {
            phases.set(phase, {
                phase,
                duration_ms: 0,
                input_tokens: 0,
                output_tokens: 0,
                cost_usd: 0,
                session_count: 0,
            });
        }
        const row = phases.get(phase);
        row.duration_ms += patch.duration_ms || 0;
        row.input_tokens += patch.input_tokens || 0;
        row.output_tokens += patch.output_tokens || 0;
        row.cost_usd += patch.cost_usd || 0;
        row.session_count += patch.session_count || 0;
    }
    // Sum durations from lifecycle events (elapsed_ms attributed to the phase
    // THIS event represents — i.e. the phase that was just entered. The
    // elapsed_ms is "time spent in the prior phase before transitioning into
    // this one." Attribute to the prior phase to keep the model intuitive.
    let priorPhase = null;
    for (const evt of lifecycleEvents) {
        const elapsed = evt.data?.elapsed_ms;
        if (priorPhase && typeof elapsed === 'number') {
            bump(priorPhase, { duration_ms: elapsed });
        }
        priorPhase = evt.data?.lifecycle_phase || null;
    }
    // Sum tokens per phase. Prefer entry.phase when set; otherwise infer from
    // lifecycle event timestamps.
    for (const entry of entries) {
        const phase = entry.phase || inferPhaseForEntry(entry.timestamp, lifecycleEvents);
        if (!phase)
            continue;
        bump(phase, {
            input_tokens: entry.input_tokens || 0,
            output_tokens: entry.output_tokens || 0,
            cost_usd: typeof entry.cost_usd === 'number' ? entry.cost_usd : 0,
            session_count: 1,
        });
    }
    const rows = Array.from(phases.values()).map((r) => ({
        ...r,
        cost_usd: Number(r.cost_usd.toFixed(6)),
    }));
    return ok({
        status: 'ok',
        command: 'tokens',
        action: 'phase-summary',
        task_id: taskId,
        entry_count: entries.length,
        lifecycle_event_count: lifecycleEvents.length,
        phases: rows,
    });
}
/**
 * Top-level dispatcher.
 */
export function cmdTokens(subcommand, projectDir, extraArgs = []) {
    if (!subcommand) {
        return err('Usage: apt-tools tokens <record|tally|compare|list|phase-summary> <project-dir> [flags]');
    }
    if (!projectDir) {
        return err(`Usage: apt-tools tokens ${subcommand} <project-dir> [flags]`);
    }
    const targetDir = resolve(projectDir);
    if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) {
        return err(`project-dir is not a directory: ${targetDir}`);
    }
    try {
        switch (subcommand) {
            case 'record':
                return cmdRecord(targetDir, extraArgs);
            case 'tally':
                return cmdTally(targetDir, extraArgs);
            case 'compare':
                return cmdCompare(targetDir, extraArgs);
            case 'list':
                return cmdList(targetDir, extraArgs);
            case 'phase-summary':
                return cmdPhaseSummary(targetDir, extraArgs);
            default:
                return err(`Unknown tokens subcommand: ${subcommand}. Expected: record, tally, compare, list, phase-summary`);
        }
    }
    catch (e) {
        if (e instanceof TokensUsageError)
            return err(e.message);
        throw e;
    }
}
//# sourceMappingURL=tokens.mjs.map