import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import { aiAssistantSlice, createDefaultSessionState, } from './ai-assistant-slice';
// GENC-1351 §5.6 / Phase 1: the new bulk loadSession + resetSession reducers.
const { loadSession, resetSession, setBlocked, setBlockedReason, setVendorBlocked, setInputValue, setMessages, setState, setSessionMenuOpen, setShowToolCalls, setShowThinkingSteps, setShowNarration, setShowAgentSwitchIndicator, setEnabledAnimations, } = aiAssistantSlice.actions;
const reduce = aiAssistantSlice.reducer;
const Suite = createLogicSuite('ai-assistant-slice-persistence');
Suite('loadSession hydrates the restorable fields and forces idle', () => {
    let start = createDefaultSessionState();
    start = reduce(start, setState('loading')); // prove it overrides any prior state
    const payload = {
        // Usage rides on the message: `loadSession` derives the session totals from the
        // restored transcript rather than from a stored counter, so the cost has to be in
        // the messages for the assertion below to see it.
        messages: [
            { role: 'user', content: 'hi', cost: 1.25, inputTokens: 100, outputTokens: 20 },
        ],
        pinnedAgentName: 'Guided Booking',
        flowOwnerAgentName: 'Guided Booking',
        contextTokens: 1200,
        contextLimit: 200000,
        activeModel: 'claude-sonnet-4-6',
        activeProviderName: 'anthropic',
    };
    const next = reduce(start, loadSession(payload));
    assert.is(next.messages.length, 1);
    assert.is(next.pinnedAgentName, 'Guided Booking');
    assert.is(next.flowOwnerAgentName, 'Guided Booking');
    assert.is(next.sessionUsage.costUsd, 1.25);
    // Derived from the same walk: 100 prompt tokens with no cache split reported, so all
    // of it is uncached input, plus the 20 generated.
    assert.is(next.sessionUsage.uncachedInputTokens, 100);
    assert.is(next.sessionUsage.outputTokens, 20);
    assert.is(next.contextTokens, 1200);
    assert.is(next.contextLimit, 200000);
    assert.is(next.activeModel, 'claude-sonnet-4-6');
    assert.is(next.activeProviderName, 'anthropic');
    assert.is(next.state, 'idle');
});
Suite('resetSession wipes back to a fresh default', () => {
    let state = createDefaultSessionState();
    state = reduce(state, setMessages([{ role: 'user', content: 'x' }]));
    state = reduce(state, loadSession({
        messages: [],
        pinnedAgentName: 'A',
        flowOwnerAgentName: 'A',
        contextTokens: 5,
        contextLimit: 10,
        activeModel: 'm',
        activeProviderName: 'p',
    }));
    const cleared = reduce(state, resetSession());
    assert.is(cleared.messages.length, 0);
    assert.is(cleared.sessionUsage.costUsd, 0);
    assert.is(cleared.pinnedAgentName, null);
    assert.is(cleared.flowOwnerAgentName, null);
    assert.is(cleared.contextTokens, undefined);
    assert.is(cleared.state, 'idle');
});
Suite('resetSession preserves the user display preferences (they survive Clear)', () => {
    // Display prefs are settings, not conversation state — Clear/New-chat must keep them
    // (GENC-1351 #10). Turn several on, plus an animation, then reset.
    let state = createDefaultSessionState();
    state = reduce(state, setShowToolCalls(true));
    state = reduce(state, setShowThinkingSteps(true));
    state = reduce(state, setShowNarration(true));
    state = reduce(state, setShowAgentSwitchIndicator(true));
    state = reduce(state, setEnabledAnimations(['halo']));
    state = reduce(state, setMessages([{ role: 'user', content: 'x' }]));
    const cleared = reduce(state, resetSession());
    // Conversation wiped…
    assert.is(cleared.messages.length, 0, 'conversation cleared');
    // …but the display prefs kept.
    assert.is(cleared.showToolCalls, true);
    assert.is(cleared.showThinkingSteps, true);
    assert.is(cleared.showNarration, true);
    assert.is(cleared.showAgentSwitchIndicator, true);
    assert.equal(cleared.enabledAnimations, ['halo']);
});
Suite('createDefaultSessionState returns independent collections', () => {
    // No aliasing between instances — resetSession must hand back a clean slate.
    assert.ok(createDefaultSessionState().messages !== createDefaultSessionState().messages);
});
// GENC-1351 Phase 3: the lifecycle (session) menu open/close flag.
Suite('setSessionMenuOpen toggles the lifecycle-menu flag; resetSession clears it', () => {
    let state = createDefaultSessionState();
    assert.is(state.sessionMenuOpen, false);
    state = reduce(state, setSessionMenuOpen(true));
    assert.is(state.sessionMenuOpen, true);
    const cleared = reduce(state, resetSession());
    assert.is(cleared.sessionMenuOpen, false);
});
// GENC-1464: the backend-blocked latch (exhausted AI budget).
Suite('setBlocked latches the flag and its reason', () => {
    let state = createDefaultSessionState();
    assert.is(state.blocked, false);
    assert.is(state.blockedReason, null);
    state = reduce(state, setBlocked({ blocked: true }));
    assert.is(state.blocked, true);
    assert.is(state.blockedReason, null, 'no reason given → null, so the element uses its default');
    state = reduce(state, setBlocked({ blocked: true, reason: 'Budget exhausted — contact billing' }));
    assert.is(state.blockedReason, 'Budget exhausted — contact billing');
});
Suite('unblocking always clears the reason', () => {
    // A stale explanation must never outlive the condition it described.
    let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'gone' }));
    state = reduce(state, setBlocked({ blocked: false }));
    assert.is(state.blocked, false);
    assert.is(state.blockedReason, null);
});
Suite('resetSession preserves the block — "New chat" does not refill the budget', () => {
    // Deliberately unlike sessionMenuOpen/compacting above: this is a backend
    // condition, not conversation state, so Clear must not hand the user a
    // working-looking composer that fails on the next send.
    let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'no funds' }));
    state = reduce(state, setMessages([{ role: 'user', content: 'x' }]));
    const cleared = reduce(state, resetSession());
    assert.is(cleared.messages.length, 0, 'the transcript is still cleared');
    assert.is(cleared.blocked, true, 'the block survives Clear');
    assert.is(cleared.blockedReason, 'no funds', 'and so does its reason');
});
Suite('re-latching without a reason preserves the host explanation', () => {
    // The race this fixes is deterministic, not theoretical: `tool-loop-end` is
    // published from the driver's `finally`, i.e. BEFORE `sendMessage()` resolves.
    // So a bus-subscribed host (what the migration guide's Option C recommends)
    // writes its detailed copy first, and the element's own latch — which may have
    // no figures to offer — lands a moment later. Treating an OMITTED reason as
    // "clear it" replaced the host's copy with generic copy every single time.
    let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'contact ops@acme.com' }));
    state = reduce(state, setBlocked({ blocked: true }));
    assert.is(state.blocked, true);
    assert.is(state.blockedReason, 'contact ops@acme.com', 'the host copy survives the re-latch');
});
Suite('reason:null clears the explanation while staying blocked', () => {
    // The explicit escape hatch the omitted case gives up: `null` still clears.
    let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'stale copy' }));
    state = reduce(state, setBlocked({ blocked: true, reason: null }));
    assert.is(state.blocked, true);
    assert.is(state.blockedReason, null, 'the banner falls back to the default copy');
});
Suite('resetSession still resets everything NOT on the preserved list', () => {
    // Pins the boundary from the other side, so the preserved list cannot quietly
    // grow: these are conversation/UI state and Clear must wipe them.
    let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'r' }));
    state = reduce(state, setSessionMenuOpen(true));
    state = reduce(state, setInputValue('half-typed message'));
    const cleared = reduce(state, resetSession());
    assert.is(cleared.inputValue, '', 'the composer draft is reset');
    assert.is(cleared.sessionMenuOpen, false, 'the panel flag is reset');
    assert.is(cleared.blocked, true, '…while the block is still preserved');
});
// ── Per-vendor latches (GENC-1464 per-vendor budgets) ──────────────────────────
Suite('setVendorBlocked walls one vendor and keeps the wall order', () => {
    let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'anthropic copy' }));
    assert.equal(state.blockedVendors, ['anthropic']);
    assert.equal(state.blockedVendorReasons, { anthropic: 'anthropic copy' });
    assert.is(state.blocked, false, 'the vendor-agnostic flag is untouched');
    state = reduce(state, setVendorBlocked({ vendor: 'gemini', blocked: true }));
    assert.equal(state.blockedVendors, ['anthropic', 'gemini'], 'order is the order they were walled');
});
Suite('walling an already-walled vendor is idempotent', () => {
    let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'gemini', blocked: true, reason: 'first' }));
    state = reduce(state, setVendorBlocked({ vendor: 'gemini', blocked: true, reason: 'second' }));
    assert.equal(state.blockedVendors, ['gemini'], 'no duplicate entry');
    assert.is(state.blockedVendorReasons.gemini, 'second');
});
Suite('the per-vendor reason honours the same three cases as setBlocked', () => {
    // The same two-writer race (driver latch landing after a bus-subscribed host)
    // applies per vendor, so "omitted keeps" has to hold here too.
    let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'host copy' }));
    state = reduce(state, setVendorBlocked({ vendor: 'anthropic', blocked: true }));
    assert.is(state.blockedVendorReasons.anthropic, 'host copy', 'omitted keeps');
    state = reduce(state, setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: null }));
    assert.is(state.blockedVendorReasons.anthropic, undefined, 'null clears');
});
Suite('releasing a vendor drops it and its copy', () => {
    let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'r' }));
    state = reduce(state, setVendorBlocked({ vendor: 'gemini', blocked: true }));
    state = reduce(state, setVendorBlocked({ vendor: 'anthropic', blocked: false }));
    assert.equal(state.blockedVendors, ['gemini']);
    assert.is(state.blockedVendorReasons.anthropic, undefined);
});
Suite('setBlocked(false) also clears every per-vendor latch', () => {
    // THE back-compat rule. A host asserting "the wall is gone" after its own
    // pre-flight (migration-GENC-1464 Option B) must not be overruled by a driver
    // latch it cannot see — without this, per-vendor would silently make the
    // documented unblock stop working.
    let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'r' }));
    state = reduce(state, setBlocked({ blocked: true, reason: 'global' }));
    state = reduce(state, setBlocked({ blocked: false }));
    assert.is(state.blocked, false);
    assert.is(state.blockedReason, null);
    assert.equal(state.blockedVendors, []);
    assert.equal(state.blockedVendorReasons, {});
});
Suite('setBlockedReason writes the copy without disturbing any latch', () => {
    // It exists precisely so the element's `blockedReason` setter cannot route
    // through `setBlocked(false, …)` — which would now clear the vendor latches.
    let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'gemini', blocked: true }));
    state = reduce(state, setBlockedReason('host copy'));
    assert.is(state.blockedReason, 'host copy');
    assert.is(state.blocked, false, 'the vendor-agnostic flag is untouched');
    assert.equal(state.blockedVendors, ['gemini'], 'and so are the per-vendor latches');
});
Suite('resetSession preserves the per-vendor latches too', () => {
    let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'no funds' }));
    state = reduce(state, setMessages([{ role: 'user', content: 'hi' }]));
    const cleared = reduce(state, resetSession());
    assert.equal(cleared.messages, [], 'the conversation is wiped');
    assert.equal(cleared.blockedVendors, ['anthropic'], 'Clear does not refill a vendor budget');
    assert.equal(cleared.blockedVendorReasons, { anthropic: 'no funds' });
});
// `resetSession` preserves its keys through `Object.assign`, whose source parameter
// is structurally permissive: a typo like `blockedResaon` would compile clean,
// silently stop preserving the real key, AND leak a junk key into serialised state.
// The reducer therefore annotates its literal `satisfies
// Partial<AiAssistantSessionState>`, which is a COMPILE-time guarantee — so the
// durable proof is a type fixture, not a runtime assertion.
//
// This mirrors the reducer's clause on the same typo. If `satisfies` ever stopped
// rejecting excess properties (a TS behaviour change, or someone widening the
// annotated type), the `@ts-expect-error` below becomes unused and the BUILD fails
// — which is exactly the signal wanted. The runtime suites above are its
// behavioural companions: they pin the preserved list from both sides.
const _typoIsRejectedAtCompileTime = {
    // @ts-expect-error — an excess/misspelled key must be rejected by `satisfies`
    blockedResaon: null,
};
void _typoIsRejectedAtCompileTime;
Suite.run();
