import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ContextNamespace } from '../../shared/context-types.js';
import type { TimelineEvent } from '../../src/daemon/timeline-event.js';
import type { MaterializationSkillReviewJob } from '../../src/context/materialization-coordinator.js';
import {
  closeLiveContextMaterializationAdmission,
  LiveContextIngestion,
  reopenLiveContextMaterializationAdmission,
} from '../../src/context/live-context-ingestion.js';
import { localOnlyCompressor, type CompressionInput, type CompressionResult } from '../../src/context/summary-compressor.js';
import { getProcessedProjectionStats, queryProcessedProjections } from '../../src/store/context-store.js';
import { cleanupIsolatedSharedContextDb, createIsolatedSharedContextDb } from '../util/shared-context-db.js';

async function successfulCompressor(input: CompressionInput): Promise<CompressionResult> {
  return {
    summary: `Compressed ${input.events.length} events after tool work.`,
    model: 'test-model',
    backend: 'test',
    usedBackup: false,
    fromSdk: true,
  };
}

describe('LiveContextIngestion', () => {
  let tempDir: string;
  const namespace: ContextNamespace = { scope: 'personal', projectId: 'github.com/acme/repo' };
  const session = {
    name: 'deck_repo_brain',
    projectName: 'repo',
    role: 'brain' as const,
    agentType: 'codex',
    projectDir: '/tmp/repo',
    state: 'idle' as const,
    restarts: 0,
    restartTimestamps: [],
    createdAt: 1,
    updatedAt: 1,
  };

  beforeEach(async () => {
    reopenLiveContextMaterializationAdmission();
    tempDir = await createIsolatedSharedContextDb('live-context-ingestion');
  });

  afterEach(async () => {
    reopenLiveContextMaterializationAdmission();
    await cleanupIsolatedSharedContextDb(tempDir);
  });

  it('stages live timeline events and materializes them when the session becomes idle', async () => {
    const ingestion = new LiveContextIngestion({ compressor: localOnlyCompressor,
      thresholds: { eventCount: 99, idleMs: 60_000, scheduleMs: 60_000 },
      sessionLookup: () => session,
      resolveBootstrap: async () => ({ namespace, diagnostics: ['test'] }),
    });

    await ingestion.handleTimelineEvent(makeEvent('user.message', 100, { text: 'Investigate memory pipeline' }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 110, { text: 'Tracing the staged events path' }));

    expect(getProcessedProjectionStats({ scope: 'personal', projectId: namespace.projectId })).toMatchObject({
      totalRecords: 0,
      stagedEventCount: 2,
      dirtyTargetCount: 1,
    });

    await ingestion.handleTimelineEvent(makeEvent('session.state', 120, { state: 'idle' }));

    expect(queryProcessedProjections({ scope: 'personal', projectId: namespace.projectId, limit: 10 })).toEqual([
      expect.objectContaining({
        class: 'recent_summary',
        summary: expect.stringContaining('**User:** Investigate memory pipeline'),
      }),
    ]);
    expect(getProcessedProjectionStats({ scope: 'personal', projectId: namespace.projectId })).toMatchObject({
      totalRecords: 1,
      stagedEventCount: 0,
      dirtyTargetCount: 0,
      pendingJobCount: 0,
    });
  });

  it('ignores streaming assistant deltas and only records the finalized assistant text', async () => {
    const ingestion = new LiveContextIngestion({ compressor: localOnlyCompressor,
      thresholds: { eventCount: 99, idleMs: 60_000, scheduleMs: 60_000 },
      sessionLookup: () => session,
      resolveBootstrap: async () => ({ namespace, diagnostics: ['test'] }),
    });

    await ingestion.handleTimelineEvent(makeEvent('user.message', 100, { text: 'Need the final answer only' }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 110, { text: 'partial', streaming: true }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 120, { text: 'final answer', streaming: false }));

    expect(getProcessedProjectionStats({ scope: 'personal', projectId: namespace.projectId })).toMatchObject({
      stagedEventCount: 2,
      dirtyTargetCount: 1,
    });

    await ingestion.handleTimelineEvent(makeEvent('session.state', 130, { state: 'idle' }));

    const [summary] = queryProcessedProjections({ scope: 'personal', projectId: namespace.projectId, limit: 10 });
    expect(summary?.summary).toContain('**User:** Need the final answer only');
    expect(summary?.summary).toContain('**Assistant:** final answer');
    expect(summary?.summary).not.toContain('partial');
  });

  it('keeps raw events staged but skips materialization while admission is closed', async () => {
    const ingestion = new LiveContextIngestion({ compressor: localOnlyCompressor,
      thresholds: { eventCount: 1, idleMs: 1, scheduleMs: 1 },
      sessionLookup: () => session,
      resolveBootstrap: async () => ({ namespace, diagnostics: ['test'] }),
    });

    closeLiveContextMaterializationAdmission('shutdown');
    await ingestion.handleTimelineEvent(makeEvent('user.message', 100, { text: 'preserve raw user event' }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 110, { text: 'preserve raw assistant event' }));
    await ingestion.handleTimelineEvent(makeEvent('session.state', 120, { state: 'idle' }));
    await ingestion.flushDueTargets(130);

    expect(getProcessedProjectionStats({ scope: 'personal', projectId: namespace.projectId })).toMatchObject({
      totalRecords: 0,
      stagedEventCount: 2,
      dirtyTargetCount: 1,
    });
  });


  it('ignores API connection error assistant turns even when they are not explicitly memoryExcluded', async () => {
    const ingestion = new LiveContextIngestion({ compressor: localOnlyCompressor,
      thresholds: { eventCount: 99, idleMs: 60_000, scheduleMs: 60_000 },
      sessionLookup: () => session,
      resolveBootstrap: async () => ({ namespace, diagnostics: ['test'] }),
    });

    await ingestion.handleTimelineEvent(makeEvent('user.message', 100, { text: 'Continue the run' }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 110, {
      text: '[API Error: Connection error. (cause: fetch failed)]',
      streaming: false,
    }));

    await ingestion.handleTimelineEvent(makeEvent('session.state', 120, { state: 'idle' }));

    const [summary] = queryProcessedProjections({ scope: 'personal', projectId: namespace.projectId, limit: 10 });
    expect(summary?.summary).toContain('**User:** Continue the run');
    expect(summary?.summary).not.toContain('API Error');
    expect(summary?.summary).not.toContain('fetch failed');
  });

  it('ignores memory-excluded assistant warnings so runtime errors do not enter processed memory', async () => {
    const ingestion = new LiveContextIngestion({ compressor: localOnlyCompressor,
      thresholds: { eventCount: 99, idleMs: 60_000, scheduleMs: 60_000 },
      sessionLookup: () => session,
      resolveBootstrap: async () => ({ namespace, diagnostics: ['test'] }),
    });

    await ingestion.handleTimelineEvent(makeEvent('user.message', 100, { text: 'Continue the run' }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 110, {
      text: '⚠️ Error: Terminal stream unavailable after max retries',
      streaming: false,
      memoryExcluded: true,
    }));

    expect(getProcessedProjectionStats({ scope: 'personal', projectId: namespace.projectId })).toMatchObject({
      stagedEventCount: 1,
      dirtyTargetCount: 1,
    });

    await ingestion.handleTimelineEvent(makeEvent('session.state', 120, { state: 'idle' }));

    const [summary] = queryProcessedProjections({ scope: 'personal', projectId: namespace.projectId, limit: 10 });
    expect(summary?.summary).toContain('**User:** Continue the run');
    expect(summary?.summary).not.toContain('Terminal stream unavailable');
  });

  it('ignores tool calls and tool results when building memory', async () => {
    const ingestion = new LiveContextIngestion({ compressor: localOnlyCompressor,
      thresholds: { eventCount: 99, idleMs: 60_000, scheduleMs: 60_000 },
      sessionLookup: () => session,
      resolveBootstrap: async () => ({ namespace, diagnostics: ['test'] }),
    });

    await ingestion.handleTimelineEvent(makeEvent('user.message', 100, { text: 'Find the final fix' }));
    await ingestion.handleTimelineEvent(makeEvent('tool.call', 110, {
      tool: 'grep',
      input: { pattern: 'bug' },
    }));
    await ingestion.handleTimelineEvent(makeEvent('tool.result', 120, {
      output: 'intermediate output',
    }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 130, { text: 'Use the final patch', streaming: false }));

    expect(getProcessedProjectionStats({ scope: 'personal', projectId: namespace.projectId })).toMatchObject({
      stagedEventCount: 2,
      dirtyTargetCount: 1,
    });

    await ingestion.handleTimelineEvent(makeEvent('session.state', 140, { state: 'idle' }));

    const [summary] = queryProcessedProjections({ scope: 'personal', projectId: namespace.projectId, limit: 10 });
    expect(summary?.summary).toContain('**User:** Find the final fix');
    expect(summary?.summary).toContain('**Assistant:** Use the final patch');
    expect(summary?.summary).not.toContain('grep');
    expect(summary?.summary).not.toContain('intermediate output');
  });

  it('uses completed tool results as threshold evidence for post-response skill auto-creation without storing tool output', async () => {
    const enqueued: MaterializationSkillReviewJob[] = [];
    const ingestion = new LiveContextIngestion({
      compressor: successfulCompressor,
      thresholds: { eventCount: 99, idleMs: 60_000, scheduleMs: 60_000, minIntervalMs: 0 },
      sessionLookup: () => session,
      resolveBootstrap: async () => ({ namespace, diagnostics: ['test'] }),
      skillReviewScheduler: {
        featureEnabled: true,
        getState: () => ({
          pendingKeys: new Set(),
          lastRunByScope: new Map(),
          dailyCountByScope: new Map(),
        }),
        policy: { toolIterationThreshold: 2, minIntervalMs: 0 },
        enqueue: (job) => { enqueued.push(job); },
      },
    });

    await ingestion.handleTimelineEvent(makeEvent('user.message', 100, { text: 'Use tools once' }));
    await ingestion.handleTimelineEvent(makeEvent('tool.result', 110, { output: 'do not store this output' }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 120, { text: 'First answer', streaming: false }));
    await ingestion.handleTimelineEvent(makeEvent('session.state', 130, { state: 'idle' }));
    expect(enqueued).toEqual([]);

    await ingestion.handleTimelineEvent(makeEvent('user.message', 200, { text: 'Use tools again' }));
    await ingestion.handleTimelineEvent(makeEvent('tool.result', 210, { output: 'also not stored' }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 220, { text: 'Second answer', streaming: false }));
    await ingestion.handleTimelineEvent(makeEvent('session.state', 230, { state: 'idle' }));
    expect(enqueued).toEqual([]);

    await ingestion.handleTimelineEvent(makeEvent('user.message', 300, { text: 'Use enough tools in one turn' }));
    await ingestion.handleTimelineEvent(makeEvent('tool.result', 310, { output: 'third hidden output' }));
    await ingestion.handleTimelineEvent(makeEvent('tool.result', 320, { output: 'fourth hidden output' }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 330, { text: 'Third answer', streaming: false }));
    await ingestion.handleTimelineEvent(makeEvent('session.state', 340, { state: 'idle' }));

    expect(enqueued).toHaveLength(1);
    expect(enqueued[0]?.trigger).toBe('tool_iteration_count');
    const summaries = queryProcessedProjections({ scope: 'personal', projectId: namespace.projectId, limit: 10 });
    expect(summaries.map((entry) => entry.summary).join('\n')).not.toContain('do not store this output');
    expect(summaries.map((entry) => entry.summary).join('\n')).not.toContain('also not stored');
  });

  it('filters hidden and failed tool results from skill-review tool-iteration evidence', async () => {
    const enqueued: MaterializationSkillReviewJob[] = [];
    const ingestion = new LiveContextIngestion({
      compressor: successfulCompressor,
      thresholds: { eventCount: 99, idleMs: 60_000, scheduleMs: 60_000, minIntervalMs: 0 },
      sessionLookup: () => session,
      resolveBootstrap: async () => ({ namespace, diagnostics: ['test'] }),
      skillReviewScheduler: {
        featureEnabled: true,
        getState: () => ({
          pendingKeys: new Set(),
          lastRunByScope: new Map(),
          dailyCountByScope: new Map(),
        }),
        policy: { toolIterationThreshold: 1, minIntervalMs: 0 },
        enqueue: (job) => { enqueued.push(job); },
      },
    });

    await ingestion.handleTimelineEvent(makeEvent('user.message', 300, { text: 'Hidden tools should not learn' }));
    await ingestion.handleTimelineEvent({ ...makeEvent('tool.result', 310, { output: 'hidden raw edit' }), hidden: true });
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 320, { text: 'First answer', streaming: false }));
    await ingestion.handleTimelineEvent(makeEvent('session.state', 330, { state: 'idle' }));
    expect(enqueued).toEqual([]);

    await ingestion.handleTimelineEvent(makeEvent('user.message', 400, { text: 'Failed tools should not learn' }));
    await ingestion.handleTimelineEvent(makeEvent('tool.result', 410, { error: 'tool failed' }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 420, { text: 'Second answer', streaming: false }));
    await ingestion.handleTimelineEvent(makeEvent('session.state', 430, { state: 'idle' }));
    expect(enqueued).toEqual([]);

    await ingestion.handleTimelineEvent(makeEvent('user.message', 500, { text: 'Visible completed tool can learn' }));
    await ingestion.handleTimelineEvent(makeEvent('tool.result', 510, { output: 'ok' }));
    await ingestion.handleTimelineEvent(makeEvent('assistant.text', 520, { text: 'Third answer', streaming: false }));
    await ingestion.handleTimelineEvent(makeEvent('session.state', 530, { state: 'idle' }));
    expect(enqueued).toHaveLength(1);
  });

  it('backfills recent timeline history for sessions that have no existing context activity', async () => {
    const ingestion = new LiveContextIngestion({ compressor: localOnlyCompressor,
      sessionLookup: () => session,
      resolveBootstrap: async () => ({ namespace, diagnostics: ['test'] }),
    });

    await ingestion.backfillSessionFromEvents(session.name, [
      makeEvent('user.message', 100, { text: 'Summarize the deployment plan' }),
      makeEvent('assistant.text', 101, { text: 'Deployment plan captured' }),
    ]);

    expect(queryProcessedProjections({ scope: 'personal', projectId: namespace.projectId, limit: 10 })).toEqual([
      expect.objectContaining({
        class: 'recent_summary',
        summary: expect.stringContaining('**Assistant:** Deployment plan captured'),
      }),
    ]);
    expect(getProcessedProjectionStats({ scope: 'personal', projectId: namespace.projectId })).toMatchObject({
      totalRecords: 1,
      stagedEventCount: 0,
      dirtyTargetCount: 0,
    });
  });

  it('rate-limits processed summaries to at most one per target every 10 seconds by default', async () => {
    const ingestion = new LiveContextIngestion({ compressor: localOnlyCompressor,
      thresholds: { eventCount: 1, idleMs: 60_000, scheduleMs: 60_000, minIntervalMs: 10_000 },
      sessionLookup: () => session,
      resolveBootstrap: async () => ({ namespace, diagnostics: ['test'] }),
    });

    await ingestion.handleTimelineEvent(makeEvent('user.message', 100, { text: 'First prompt' }));
    await ingestion.handleTimelineEvent(makeEvent('session.state', 101, { state: 'idle' }));
    expect(queryProcessedProjections({ scope: 'personal', projectId: namespace.projectId, limit: 10 })).toHaveLength(1);

    await ingestion.handleTimelineEvent(makeEvent('user.message', 105, { text: 'Second prompt too soon' }));
    await ingestion.handleTimelineEvent(makeEvent('session.state', 106, { state: 'idle' }));
    expect(queryProcessedProjections({ scope: 'personal', projectId: namespace.projectId, limit: 10 })).toHaveLength(1);
    expect(getProcessedProjectionStats({ scope: 'personal', projectId: namespace.projectId })).toMatchObject({
      stagedEventCount: 1,
      dirtyTargetCount: 1,
    });

    await ingestion.flushDueTargets(10_200);
    const summaries = queryProcessedProjections({ scope: 'personal', projectId: namespace.projectId, limit: 10 });
    expect(summaries).toHaveLength(2);
    expect(summaries[0]?.summary).toContain('Second prompt too soon');
  });
});

function makeEvent(type: TimelineEvent['type'], ts: number, payload: Record<string, unknown>): TimelineEvent {
  return {
    eventId: `${type}-${ts}`,
    sessionId: 'deck_repo_brain',
    ts,
    seq: ts,
    epoch: 1,
    source: 'daemon',
    confidence: 'high',
    type,
    payload,
  };
}
