import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import { assembleDebugLog, diagnosticsMessageKey, withFreshMetaSnapshot, } from './diagnostics';
// GENC-1351 §5.8: `assembleDebugLog` reassembles the forward-only diagnostics
// stream back into the `{ readme, timeline, meta }` debug-log shape — timeline
// sorted by timestamp, meta-snapshots pulled out with the latest winning.
const README = ['read me'];
const Suite = createLogicSuite('assembleDebugLog');
Suite('sorts the timeline by timestamp, tie-breaking event → turn → message', () => {
    const entries = [
        { kind: 'message', timestamp: '2026-01-01T00:00:02.000Z', content: 'later msg' },
        { kind: 'turn', timestamp: '2026-01-01T00:00:01.000Z', turnIndex: '0' },
        { kind: 'event', timestamp: '2026-01-01T00:00:01.000Z', type: 'turn.start' },
        { kind: 'message', timestamp: '2026-01-01T00:00:01.000Z', content: 'first msg' },
    ];
    const { timeline } = assembleDebugLog(entries, README);
    // Co-timestamped @ …01: event, then turn, then message; the …02 message last.
    assert.equal(timeline.map((e) => e.kind), ['event', 'turn', 'message', 'message']);
    assert.is(timeline[3].content, 'later msg');
});
Suite('pulls meta-snapshots out of the timeline and surfaces the latest as `meta`', () => {
    const entries = [
        { kind: 'meta-snapshot', timestamp: '2026-01-01T00:00:01.000Z', meta: { model: 'old' } },
        { kind: 'message', timestamp: '2026-01-01T00:00:02.000Z', content: 'hi' },
        { kind: 'meta-snapshot', timestamp: '2026-01-01T00:00:03.000Z', meta: { model: 'new' } },
    ];
    const { timeline, meta } = assembleDebugLog(entries, README);
    assert.equal(timeline.map((e) => e.kind), ['message'], 'meta-snapshots do not leak into the timeline');
    assert.equal(meta, { model: 'new' }, 'the newest meta-snapshot surfaces as top-level meta');
});
Suite('re-prepends the passed readme and tolerates an empty stream', () => {
    const empty = assembleDebugLog([], README);
    assert.is(empty.readme, README);
    assert.equal(empty.timeline, []);
    assert.is(empty.meta, undefined, 'no meta-snapshot → undefined meta');
});
Suite('reassembles two loads into one lifetime timeline (the stitch)', () => {
    // Load 1's appended entries + load 2's — as loadDiagnostics would return them,
    // in arbitrary order — stitch back chronologically.
    const load2 = [
        { kind: 'message', timestamp: '2026-01-01T00:10:00.000Z', content: 'reload msg' },
        { kind: 'meta-snapshot', timestamp: '2026-01-01T00:10:00.000Z', meta: { load: 2 } },
    ];
    const load1 = [
        { kind: 'message', timestamp: '2026-01-01T00:00:00.000Z', content: 'first msg' },
        { kind: 'meta-snapshot', timestamp: '2026-01-01T00:00:00.000Z', meta: { load: 1 } },
    ];
    const { timeline, meta } = assembleDebugLog([...load2, ...load1], README);
    assert.equal(timeline.filter((e) => e.kind === 'message').map((e) => e.content), ['first msg', 'reload msg'], 'messages ordered across loads regardless of input order');
    assert.equal(meta, { load: 2 }, 'newest load’s meta-snapshot surfaces');
});
Suite('collapses exact-duplicate entries but keeps genuinely-distinct ones', () => {
    // Two element instances (bubble + panel) can each append the same message, and a
    // reload's second instance can re-append it — byte-identical copies. Two real
    // `assistant.connected` (from two instances) differ by `index` and must be kept.
    const msg = {
        kind: 'message',
        timestamp: '2026-01-01T00:00:01.000Z',
        role: 'user',
        content: 'hi',
    };
    const connA = {
        kind: 'event',
        timestamp: '2026-01-01T00:00:00.000Z',
        type: 'assistant.connected',
        index: 0,
    };
    const connB = Object.assign(Object.assign({}, connA), { index: 1 });
    const { timeline } = assembleDebugLog([msg, connA, Object.assign({}, msg), connB, connA], README);
    assert.is(timeline.filter((e) => e.kind === 'message').length, 1, 'identical message collapsed');
    assert.is(timeline.filter((e) => e.type === 'assistant.connected').length, 2, 'two distinct connects (different index) kept; the repeated connA collapsed');
});
Suite('lifts the newest snapshot’s session usage to the top of the log', () => {
    // The four buckets + USD are the headline of a cost investigation, and `meta` is a
    // large block to go digging in — so they surface at the top level. Same object, not a
    // second derivation, and taken from the SAME snapshot that becomes `meta`.
    const usage = {
        costUsd: 1.25,
        uncachedInputTokens: 1000,
        cacheReadTokens: 9000,
        cacheWriteTokens: 500,
        outputTokens: 300,
    };
    const entries = [
        {
            kind: 'meta-snapshot',
            timestamp: '2026-01-01T00:00:01.000Z',
            meta: { context: { sessionUsage: Object.assign(Object.assign({}, usage), { costUsd: 0.01 }) } },
        },
        {
            kind: 'meta-snapshot',
            timestamp: '2026-01-01T00:00:09.000Z',
            meta: { context: { sessionUsage: usage } },
        },
    ];
    assert.equal(assembleDebugLog(entries, README).sessionUsage, usage);
});
Suite('leaves session usage undefined when no snapshot carries it', () => {
    // A stream with no meta-snapshot, or one written before the field existed. Absent
    // rather than zeroed: "not recorded" must not read as "this session spent nothing".
    assert.is(assembleDebugLog([], README).sessionUsage, undefined);
    const noContext = [
        { kind: 'meta-snapshot', timestamp: '2026-01-01T00:00:01.000Z', meta: { host: 'localhost' } },
    ];
    assert.is(assembleDebugLog(noContext, README).sessionUsage, undefined);
});
Suite('collapses the turn pair even when its rendered systemPrompt differs', () => {
    // The pair is produced by two separate flushes, and `buildTimelineEntries` renders
    // `systemPrompt` relative to the preceding snapshots — so if the snapshot holding the
    // full prompt is evicted from the ring buffer between the two, the surviving turn
    // renders its prompt in full where the earlier copy had the '<repeated>' marker. Any
    // identity derived from the serialized entry breaks here and the log shows one model
    // call as two turns, one unpriced. Identity is turnIndex + timestamp + agentName.
    const unpriced = {
        kind: 'turn',
        turnIndex: '7',
        timestamp: '2026-01-01T00:00:01.000Z',
        agentName: 'Trade Operations',
        systemPrompt: '<repeated — identical to turn 0>',
        toolNames: [],
    };
    const priced = Object.assign(Object.assign({}, unpriced), { systemPrompt: 'the full prompt, now that turn 0 has been evicted', usage: {
            costUsd: 0.5,
            uncachedInputTokens: 100,
            cacheReadTokens: 0,
            cacheWriteTokens: 0,
            outputTokens: 20,
        } });
    const turns = assembleDebugLog([unpriced, priced], README).timeline.filter((e) => e.kind === 'turn');
    assert.is(turns.length, 1, 'still one model call');
    assert.equal(turns[0].usage, priced.usage, 'and it is the priced copy');
});
Suite('keeps same-index turns from different loads and different sub-agents apart', () => {
    // `turnIndex` restarts at '0' on every page load, so a lifetime log holds one turn '0'
    // per load; two sub-agents invoked in one parent turn share the index prefix and are
    // separated by `agentName` (see `forwardSubAgentSnapshots`).
    const load1 = {
        kind: 'turn',
        turnIndex: '0',
        timestamp: '2026-01-01T00:00:01.000Z',
        agentName: 'Booker',
        toolNames: [],
    };
    const load2 = Object.assign(Object.assign({}, load1), { timestamp: '2026-01-02T09:00:00.000Z' });
    const sibling = Object.assign(Object.assign({}, load1), { turnIndex: '3-1', agentName: 'Planner' });
    const cousin = Object.assign(Object.assign({}, sibling), { agentName: 'Grounding' });
    const turns = assembleDebugLog([load1, load2, sibling, cousin], README).timeline.filter((e) => e.kind === 'turn');
    assert.is(turns.length, 4, 'four distinct calls, none collapsed');
});
Suite('collapses the unpriced and priced copies of one turn onto the priced one', () => {
    // A turn entry is persisted when it is created (before its model call) and again once
    // `usage` is back-filled, so a stitched stream carries both. They are ONE model call
    // and must read as one — with the cost, or the re-emit gained nothing.
    const unpriced = {
        kind: 'turn',
        turnIndex: '0',
        timestamp: '2026-01-01T00:00:01.000Z',
        toolNames: [],
    };
    const priced = Object.assign(Object.assign({}, unpriced), { usage: {
            costUsd: 0.5,
            uncachedInputTokens: 100,
            cacheReadTokens: 0,
            cacheWriteTokens: 0,
            outputTokens: 20,
        } });
    for (const order of [
        [unpriced, priced],
        [priced, unpriced],
    ]) {
        const turns = assembleDebugLog(order, README).timeline.filter((e) => e.kind === 'turn');
        assert.is(turns.length, 1, 'one turn, not two');
        assert.equal(turns[0].usage, priced.usage, 'the priced copy wins regardless of stream order');
    }
});
Suite('keeps two genuinely-different turns that differ only in usage', () => {
    // The collapse keys on everything EXCEPT usage, so distinct calls stay distinct — they
    // differ in timestamp (and turnIndex) whatever else they share.
    const a = {
        kind: 'turn',
        turnIndex: '0',
        timestamp: '2026-01-01T00:00:01.000Z',
        toolNames: [],
    };
    const b = Object.assign(Object.assign({}, a), { turnIndex: '1', timestamp: '2026-01-01T00:00:02.000Z' });
    const turns = assembleDebugLog([a, b], README).timeline.filter((e) => e.kind === 'turn');
    assert.is(turns.length, 2);
});
Suite.run();
// The download path's swap: a stored stream's `meta-snapshot`s replaced by an export-time
// one. This is the fix for a SILENT bug — the persisted stream only re-appends that block
// when the config signature changes, so a stitched lifetime log reported the totals frozen
// at the session's first flush (near-zero spend against a transcript full of priced
// messages) and nothing about it looked wrong.
const FreshSuite = createLogicSuite('withFreshMetaSnapshot');
const storedStream = [
    {
        kind: 'meta-snapshot',
        timestamp: '2026-01-01T00:00:00.000Z',
        meta: { context: { sessionUsage: { costUsd: 0.01 } } },
        dedupSignature: 'cfg-1',
    },
    { kind: 'message', timestamp: '2026-01-01T00:00:01.000Z', role: 'user', content: 'hi' },
    { kind: 'turn', turnIndex: '0', timestamp: '2026-01-01T00:00:02.000Z', toolNames: [] },
    { kind: 'event', index: 0, timestamp: '2026-01-01T00:00:03.000Z', type: 'turn.end' },
];
const freshSnapshot = {
    kind: 'meta-snapshot',
    timestamp: '2026-01-01T02:00:00.000Z',
    meta: {
        context: {
            sessionUsage: {
                costUsd: 1.25,
                uncachedInputTokens: 1000,
                cacheReadTokens: 9000,
                cacheWriteTokens: 500,
                outputTokens: 300,
            },
        },
    },
    dedupSignature: 'cfg-1',
};
FreshSuite('reports the export-time totals, not the frozen stored ones', () => {
    var _a, _b;
    const log = assembleDebugLog(withFreshMetaSnapshot(storedStream, freshSnapshot), README);
    assert.is((_a = log.sessionUsage) === null || _a === void 0 ? void 0 : _a.costUsd, 1.25, 'the fresh snapshot supplies the totals');
    assert.is((_b = log.sessionUsage) === null || _b === void 0 ? void 0 : _b.cacheReadTokens, 9000);
    assert.equal(log.meta, freshSnapshot.meta, 'and the whole meta block is the fresh one');
});
FreshSuite('keeps every non-snapshot entry', () => {
    const timeline = assembleDebugLog(withFreshMetaSnapshot(storedStream, freshSnapshot), README).timeline;
    assert.equal(timeline.map((e) => e.kind), ['message', 'turn', 'event'], 'the conversation is untouched — only the snapshots are swapped');
});
FreshSuite('wins even when the stored snapshot claims a later time', () => {
    var _a;
    // A stream written on another machine can carry a skewed clock, and reassembly keeps
    // whichever snapshot claims the later timestamp — so the stale one must be REMOVED
    // rather than out-timestamped.
    const skewed = [
        Object.assign(Object.assign({}, storedStream[0]), { timestamp: '2099-01-01T00:00:00.000Z' }),
        ...storedStream.slice(1),
    ];
    const log = assembleDebugLog(withFreshMetaSnapshot(skewed, freshSnapshot), README);
    assert.is((_a = log.sessionUsage) === null || _a === void 0 ? void 0 : _a.costUsd, 1.25);
});
FreshSuite.run();
// GENC-1461: the reasoning/narration/answer split (GENC-1411) emits several messages from ONE model
// response, so they share `timestamp` + `role` + `subAgentOf`. The message identity key must still
// tell them apart — otherwise the forward-capture dedup treats them as one and drops all but the
// first (a server generation's answer message vanished from the persisted download while its
// reasoning survived).
const KeySuite = createLogicSuite('diagnosticsMessageKey');
KeySuite('gives co-timestamped split messages (reasoning vs answer) distinct keys', () => {
    const base = {
        kind: 'message',
        role: 'assistant',
        timestamp: '2026-01-01T00:00:05.000Z',
    };
    const reasoning = Object.assign(Object.assign({}, base), { category: 'reasoning', content: 'thinking…' });
    const answer = Object.assign(Object.assign({}, base), { content: 'the generated code' }); // no category = the answer
    const kReasoning = diagnosticsMessageKey(reasoning);
    const kAnswer = diagnosticsMessageKey(answer);
    assert.ok(kReasoning, 'reasoning message has a key');
    assert.ok(kAnswer, 'answer message has a key');
    assert.not.equal(kReasoning, kAnswer, 'a reasoning and an answer split from one response must not collide');
});
KeySuite('still collapses a genuine re-append of the same message (same key)', () => {
    const m = {
        kind: 'message',
        role: 'assistant',
        timestamp: '2026-01-01T00:00:05.000Z',
        category: 'reasoning',
        content: 'thinking…',
    };
    // Two instances / a reload re-appending the SAME message must still share one key.
    assert.equal(diagnosticsMessageKey(m), diagnosticsMessageKey(Object.assign({}, m)));
});
KeySuite('returns null when the entry has no timestamp (treated as always-new)', () => {
    assert.is(diagnosticsMessageKey({ kind: 'message', role: 'assistant' }), null);
});
KeySuite.run();
