// Read pane-grade metadata for a single claude session JSONL on disk.
// Claude writes `${CLAUDE_CONFIG_DIR or ~/.claude}/projects/<slug>/<id>.jsonl`
// for every session it spawns (see `jsonl-path.ts` for the path derivation,
// which honours CLAUDE_CONFIG_DIR for brand isolation).
//
// Each enriched entry carries:
//   - displayName (user override from UserTitleStore, else tail.aiTitle,
//     else null — Task 253 added the operator-stamped layer over the
//     prior aiTitle-only resolution from Task 146)
//   - titleSource ('user' | 'ai' | null — matches Claude.app's local
//     summary file shape so the UI can render a provenance indicator
//     next to the title)
//   - lastMessageAt (ISO of the last user|assistant turn parsed from the
//     JSONL tail)
//   - turnCount (count of user|assistant turns in the parsed tail)
//   - capped (true when the tail was clipped — turn count is a lower bound)
//
// Surviving callers (post-Task 538):
//   - http-server `toPayload` (drives /spawn, /rc-spawn, /:sessionId/meta),
//     passing `jsonlReadMaxBytes` (~1 MB) for richer per-row data.
//   - http-server `rowToPayload` (drives /events SSE), passing
//     `LIST_TAIL_BYTES` (64 KB) so a steady SSE stream stays cheap.
// The module-scoped tail cache keyed on `${path}|${mtimeMs}|${size}|${tail}`
// short-circuits repeated parses on unchanged files.
import { existsSync, openSync, closeSync, readSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { claudeStateRoot, projectSlugForCwd } from './jsonl-path.js';
// `ai-title-captured` log line fires once per JSONL path per process
// lifetime, the first time a tail parse extracts a non-null aiTitle for a path
// that had none before. Set is reset on process restart by design — the log is
// a state-transition signal, not a deduplicated identity registry.
const observedAiTitles = new Set();
// Tail-parse cache. Key: `${path}|${mtimeMs}|${size}|${tailMaxBytes}`.
// Invalidated implicitly on any append (mtime or size shifts).
const tailCache = new Map();
/**
 * Parse the trailing window of a JSONL file (or the whole file if it fits
 * under `tailMaxBytes`). Returns the timestamp of the last user|assistant
 * line and the count of user|assistant lines seen in the window. The
 * partial line at the head of the window (when capped) is discarded —
 * `\n` after the first read byte is the alignment anchor.
 */
function parseJsonlTail(jsonlPath, sizeBytes, tailMaxBytes) {
    const cacheKey = `${jsonlPath}|${statSync(jsonlPath).mtimeMs}|${sizeBytes}|${tailMaxBytes}`;
    const cached = tailCache.get(jsonlPath);
    if (cached && cached.key === cacheKey) {
        return { lastMessageAt: cached.lastMessageAt, turnCount: cached.turnCount, capped: cached.capped, aiTitle: cached.aiTitle, model: cached.model };
    }
    const capped = sizeBytes > tailMaxBytes;
    const readBytes = Math.min(sizeBytes, tailMaxBytes);
    const startOffset = sizeBytes - readBytes;
    let chunk = '';
    if (readBytes > 0) {
        const fd = openSync(jsonlPath, 'r');
        try {
            const buf = Buffer.alloc(readBytes);
            readSync(fd, buf, 0, readBytes, startOffset);
            chunk = buf.toString('utf8');
        }
        finally {
            closeSync(fd);
        }
    }
    // Drop the partial leading line when the window was clipped.
    if (capped) {
        const nl = chunk.indexOf('\n');
        chunk = nl === -1 ? '' : chunk.slice(nl + 1);
    }
    let turnCount = 0;
    let lastMessageAt = null;
    let aiTitle = null;
    let model = null;
    for (const line of chunk.split('\n')) {
        if (!line)
            continue;
        let obj;
        try {
            obj = JSON.parse(line);
        }
        catch {
            continue;
        }
        const t = obj.type;
        if (t === 'user' || t === 'assistant') {
            turnCount += 1;
            if (typeof obj.timestamp === 'string')
                lastMessageAt = obj.timestamp;
            if (t === 'assistant' && obj.message && typeof obj.message === 'object') {
                const m = obj.message.model;
                if (typeof m === 'string' && m)
                    model = m;
            }
            continue;
        }
        // claude desktop writes one ai-title line per summary cycle.
        // We surface the latest one in the parsed window.
        if (t === 'ai-title' && typeof obj.aiTitle === 'string' && obj.aiTitle.trim()) {
            aiTitle = obj.aiTitle.trim();
        }
    }
    const entry = { key: cacheKey, lastMessageAt, turnCount, capped, aiTitle, model };
    tailCache.set(jsonlPath, entry);
    return { lastMessageAt, turnCount, capped, aiTitle, model };
}
/**
 * Find a single dehydrated row by claudeSessionId. Returns null if the JSONL
 * is missing. Drives /:sessionId/meta, the /events SSE rowToPayload, and the
 * /spawn + /rc-spawn return-payload builders via http-server's `toPayload`.
 */
export function readJsonlSession(spawnCwd, claudeSessionId, tailMaxBytes, logger, userTitleStore) {
    const slug = projectSlugForCwd(spawnCwd);
    const projectsDir = join(claudeStateRoot(), 'projects', slug);
    const jsonlPath = join(projectsDir, `${claudeSessionId}.jsonl`);
    if (!existsSync(jsonlPath))
        return null;
    const stat = statSync(jsonlPath);
    const tail = parseJsonlTail(jsonlPath, stat.size, tailMaxBytes);
    maybeLogAiTitleCaptured(jsonlPath, claudeSessionId, tail.aiTitle, logger);
    const resolved = resolveDisplayNameAndSource(claudeSessionId, tail.aiTitle, userTitleStore);
    return {
        claudeSessionId,
        jsonlPath,
        startedAt: stat.mtime.toISOString(),
        sizeBytes: stat.size,
        displayName: resolved.displayName,
        titleSource: resolved.titleSource,
        aiTitle: tail.aiTitle,
        lastMessageAt: tail.lastMessageAt,
        turnCount: tail.turnCount,
        capped: tail.capped,
        model: tail.model,
    };
}
/** Resolve (displayName, titleSource) from the user-title store and the
 *  jsonl's aiTitle. User override wins; falls through to aiTitle; null
 *  when neither has a value. Single resolution site — http-server's
 *  pre-jsonl helper does the same merge for live PTYs with no JSONL yet. */
function resolveDisplayNameAndSource(claudeSessionId, aiTitle, userTitleStore) {
    const userTitle = userTitleStore?.get(claudeSessionId)?.title;
    if (userTitle)
        return { displayName: userTitle, titleSource: 'user' };
    const trimmedAi = aiTitle?.trim();
    if (trimmedAi)
        return { displayName: trimmedAi, titleSource: 'ai' };
    return { displayName: null, titleSource: null };
}
function maybeLogAiTitleCaptured(jsonlPath, claudeSessionId, aiTitle, logger) {
    if (!aiTitle)
        return;
    if (observedAiTitles.has(jsonlPath))
        return;
    observedAiTitles.add(jsonlPath);
    logger?.(`ai-title-captured sessionId=${claudeSessionId.slice(0, 8)} aiTitle=${aiTitle.slice(0, 80)}`);
}
//# sourceMappingURL=jsonl-enumerator.js.map