/**
 * One session-summarizer pass (PHNX-3939) — the work the background
 * SessionSummarizerService does per tick, factored out so it is testable with an
 * injected model stub and no daemon scaffolding.
 *
 * Off the request path by construction: it reads THIS box's already-published
 * live sessions (the warm cache the session-state service writes — never a fresh
 * gather) and, per session, reuses the transcript-keyed `session_summaries` row
 * until the transcript bytes change, so an unchanged session costs one indexed
 * read and zero model calls. When the summarizer is disabled or has no endpoint,
 * the pass does nothing at all.
 */
import * as fs from 'node:fs';
import { readSessionSummary, readSessionSummaryAny, writeSessionSummary, } from '../session/db.js';
import { isActiveSessionsJournalReaderRecent, readActiveSessionsCache, } from '../session/session-cache.js';
import { resolveSummarizerConfig, isSummarizerRunnable } from './config.js';
import { summarize as defaultSummarize } from './summarize.js';
/** Max sessions summarized per tick — a debounce ceiling on local-model calls. */
export const SUMMARIZER_MAX_PER_TICK = 8;
/**
 * Merge model checkpoint TEXTS with any prior stored checkpoints so a line that
 * already existed keeps its original `at`, and only genuinely new lines get
 * stamped `now`. Order follows the model output (newest last).
 */
function stampCheckpoints(texts, prior, nowIso) {
    const priorAt = new Map((prior ?? []).map((c) => [c.text, c.at]));
    return texts.map((text) => ({ text, at: priorAt.get(text) ?? nowIso }));
}
/**
 * Run one pass. Returns per-outcome counts. Never throws — a per-session failure
 * is isolated so one bad transcript can't stall the whole tick.
 */
export async function runSummarizerPass(opts = {}) {
    const now = opts.now ?? Date.now();
    const config = opts.config ?? resolveSummarizerConfig();
    const result = { disabled: false, computed: 0, reused: 0, skipped: 0 };
    if (!isSummarizerRunnable(config)) {
        result.disabled = true;
        return result;
    }
    let sessions = opts.sessions;
    if (!sessions) {
        // Reader-gated like the active-sessions warm tick: no watcher, no work.
        if (opts.requireReader !== false && !isActiveSessionsJournalReaderRecent(now))
            return result;
        sessions = readActiveSessionsCache('local')?.sessions ?? [];
    }
    const stat = opts.statFile ?? ((p) => {
        const s = fs.statSync(p);
        return { mtimeMs: s.mtimeMs, size: s.size };
    });
    const runSummarize = opts.summarizeImpl ?? defaultSummarize;
    const maxPerTick = opts.maxPerTick ?? SUMMARIZER_MAX_PER_TICK;
    const nowIso = new Date(now).toISOString();
    let budget = maxPerTick;
    for (const s of sessions) {
        if (opts.signal?.aborted)
            break;
        if (budget <= 0)
            break;
        const id = s.sessionId;
        const file = s.sessionFile;
        if (!id || !file)
            continue;
        let stamp;
        try {
            const st = stat(file);
            stamp = { fileMtimeMs: Math.round(st.mtimeMs), fileSize: st.size };
        }
        catch {
            continue; // transcript unreadable this tick — try again next time
        }
        // Cached for these exact bytes → never recompute (the core "blazing fast" rule).
        if (readSessionSummary(id, stamp)) {
            result.reused++;
            continue;
        }
        budget--;
        const prompt = (s.firstUserMessage ?? s.topic ?? '').trim();
        const prior = readSessionSummaryAny(id);
        if (!prompt) {
            // No extractable intent yet — cache a skip against these bytes so we don't
            // re-attempt every tick; a later transcript write (new bytes) retries.
            writeSessionSummary({ id, ...stamp, summary: { summaryState: 'skipped' } });
            result.skipped++;
            continue;
        }
        let computed;
        try {
            computed = await runSummarize(prompt, { todos: s.todos, plan: s.plan, phase: s.phase }, { baseUrl: config.baseUrl, model: config.model, ...(opts.signal ? { signal: opts.signal } : {}) });
        }
        catch {
            computed = undefined;
        }
        if (!computed) {
            writeSessionSummary({ id, ...stamp, summary: { summaryState: 'skipped' } });
            result.skipped++;
            continue;
        }
        const entry = {
            // Goal is computed ONCE at first sight — a prior goal is kept across
            // progress deltas so it stays stable while checkpoints/checklist refresh.
            goal: prior?.goal ?? computed.goal,
            checkpoints: stampCheckpoints(computed.checkpoints, prior?.checkpoints, nowIso),
            summaryChecklist: computed.checklist,
            summaryState: 'ready',
        };
        writeSessionSummary({ id, ...stamp, summary: entry });
        result.computed++;
    }
    return result;
}
