import { RuntimeInternalService } from './runtime-internal.service';

describe('RuntimeInternalService', () => {
  let service: RuntimeInternalService;

  // Prisma mocks
  let mockAiRuntimeJob: any;
  let mockLearningAnalysisSnapshot: any;
  let mockAiRuntimeResult: any;
  let mockRuntimeInstance: any;
  let mockModelInvocationLog: any;
  let mockAiLearningAnalysis: any;
  let mockWeakPointCandidate: any;
  let mockNextActionRecommendation: any;
  let mockQuestionGenerationPlan: any;
  let mockFlashcardGenerationPlan: any;
  let mockQuiz: any;
  let mockQuizQuestion: any;
  let mockFlashcard: any;
  let mockKnowledgeItem: any;
  let mockNotification: any;

  // Service dependency mocks
  let mockUserAi: any;
  let mockSnapshotBuilder: any;

  const mockSnapshot = {
    id: 'snap-1',
    snapshotVersion: 'ai_snapshot_v1',
    sourceDataVersion: '1.0',
    expiresAt: new Date(Date.now() + 3600_000),
    privacyScope: { allowUserProfile: true },
    userProfile: { displayName: 'Test' },
    aiSettings: { modelPreference: 'deepseek' },
    deviceContext: { platform: 'ios' },
    learningBehaviorSummary: { totalSessions: 10 },
    materialProgressSummary: { materialsRead: 5 },
    contentStructureSummary: { topics: 3 },
    behaviorSignals: { avgSessionMin: 15 },
    scoreSignals: { avgScore: 0.8 },
    constraints: { qualityPreference: 'standard' },
    allowedModelFields: ['userProfile', 'behaviorSignals'],
  };

  beforeEach(() => {
    mockAiRuntimeJob = {
      findMany: jest.fn(),
      findUnique: jest.fn(),
      findFirst: jest.fn(),
      updateMany: jest.fn(),
      update: jest.fn(),
    };
    mockLearningAnalysisSnapshot = {
      findMany: jest.fn(),
      findUnique: jest.fn(),
    };
    mockAiRuntimeResult = {
      findFirst: jest.fn(),
      findUnique: jest.fn(),
      create: jest.fn(),
    };
    mockRuntimeInstance = { upsert: jest.fn() };
    mockModelInvocationLog = { create: jest.fn() };
    mockAiLearningAnalysis = { create: jest.fn() };
    mockWeakPointCandidate = { updateMany: jest.fn(), create: jest.fn() };
    mockNextActionRecommendation = { updateMany: jest.fn(), create: jest.fn() };
    mockQuestionGenerationPlan = { updateMany: jest.fn() };
    mockFlashcardGenerationPlan = { updateMany: jest.fn() };
    mockQuiz = { create: jest.fn(), update: jest.fn() };
    mockQuizQuestion = { findMany: jest.fn(), create: jest.fn() };
    mockFlashcard = { findMany: jest.fn(), create: jest.fn() };
    mockKnowledgeItem = { findMany: jest.fn().mockResolvedValue([]) };
    mockNotification = { create: jest.fn() };

    const mockPrisma = {
      aiRuntimeJob: mockAiRuntimeJob,
      learningAnalysisSnapshot: mockLearningAnalysisSnapshot,
      aiRuntimeResult: mockAiRuntimeResult,
      runtimeInstance: mockRuntimeInstance,
      modelInvocationLog: mockModelInvocationLog,
      aiLearningAnalysis: mockAiLearningAnalysis,
      weakPointCandidate: mockWeakPointCandidate,
      nextActionRecommendation: mockNextActionRecommendation,
      questionGenerationPlan: mockQuestionGenerationPlan,
      flashcardGenerationPlan: mockFlashcardGenerationPlan,
      quiz: mockQuiz,
      quizQuestion: mockQuizQuestion,
      flashcard: mockFlashcard,
      knowledgeItem: mockKnowledgeItem,
      notification: mockNotification,
    } as any;

    mockUserAi = { resolveCredentialForJob: jest.fn() };
    mockSnapshotBuilder = { buildSnapshot: jest.fn() };

    service = new RuntimeInternalService(mockPrisma as any, mockUserAi, mockSnapshotBuilder);
    RuntimeInternalService.persistResultFailures = 0;
    RuntimeInternalService.notifyFailures = 0;
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  // ═══════════════════════════════════════════════════════════════════
  // pollJobs
  // ═══════════════════════════════════════════════════════════════════

  describe('pollJobs', () => {
    const pendingJobs = [
      { id: 'j1', jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1', priority: 1, snapshotId: 'snap-1', promptVersion: 'v1', outputSchemaVersion: 'ov1', apiKeyMode: 'platform_key', credentialId: null },
      { id: 'j2', jobType: 'flashcard_generation', targetType: 'material', targetId: 'm1', priority: 2, snapshotId: null, promptVersion: 'v1', outputSchemaVersion: 'ov2', apiKeyMode: 'platform_key', credentialId: null },
    ];

    it('returns pending jobs for supported job types', async () => {
      mockAiRuntimeJob.findMany.mockResolvedValue(pendingJobs);
      const result = await service.pollJobs('rt-1', ['quiz_generation', 'flashcard_generation'], 10);
      expect(result.jobs).toHaveLength(2);
    });

    it('caps limit to 50 when no limit given (defaults to 5)', async () => {
      mockAiRuntimeJob.findMany.mockResolvedValue([]);
      await service.pollJobs('rt-1', ['quiz_generation'], undefined as any);
      expect(mockAiRuntimeJob.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 5 }));
    });

    it('caps limit to 50 even if higher given', async () => {
      mockAiRuntimeJob.findMany.mockResolvedValue([]);
      await service.pollJobs('rt-1', ['quiz_generation'], 100);
      expect(mockAiRuntimeJob.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 50 }));
    });

    it('filters by outputSchemaVersion when capabilities declare supportedOutputSchemaVersions', async () => {
      mockAiRuntimeJob.findMany.mockResolvedValue([]);
      mockRuntimeInstance.upsert.mockResolvedValue({});
      await service.pollJobs('rt-1', ['quiz_generation'], 10, { supportedOutputSchemaVersions: ['ov1', 'ov2'] });
      expect(mockAiRuntimeJob.findMany).toHaveBeenCalledWith(
        expect.objectContaining({ where: expect.objectContaining({ outputSchemaVersion: { in: ['ov1', 'ov2'] } }) }),
      );
    });

    it('upserts RuntimeInstance when capabilities have snapshot or output version info', async () => {
      mockAiRuntimeJob.findMany.mockResolvedValue([]);
      mockRuntimeInstance.upsert.mockResolvedValue({});
      await service.pollJobs('rt-1', ['quiz_generation'], 10, { supportedSnapshotVersions: ['ai_snapshot_v1'] });
      expect(mockRuntimeInstance.upsert).toHaveBeenCalled();
    });

    it('does not upsert RuntimeInstance when no version capabilities', async () => {
      mockAiRuntimeJob.findMany.mockResolvedValue([]);
      await service.pollJobs('rt-1', ['quiz_generation'], 10, { someOtherCap: true });
      expect(mockRuntimeInstance.upsert).not.toHaveBeenCalled();
    });

    it('post-filters jobs by snapshotVersion compatibility', async () => {
      mockAiRuntimeJob.findMany.mockResolvedValue(pendingJobs);
      mockRuntimeInstance.upsert.mockResolvedValue({});
      mockLearningAnalysisSnapshot.findMany.mockResolvedValue([{ id: 'snap-1', snapshotVersion: 'ai_snapshot_v2' }]);
      const result = await service.pollJobs('rt-1', ['quiz_generation'], 10, { supportedSnapshotVersions: ['ai_snapshot_v1'] });
      expect(result.jobs).toHaveLength(1);
      expect(result.jobs[0].id).toBe('j2');
    });

    it('keeps jobs with compatible snapshot version', async () => {
      mockAiRuntimeJob.findMany.mockResolvedValue(pendingJobs);
      mockRuntimeInstance.upsert.mockResolvedValue({});
      mockLearningAnalysisSnapshot.findMany.mockResolvedValue([{ id: 'snap-1', snapshotVersion: 'ai_snapshot_v1' }]);
      const result = await service.pollJobs('rt-1', ['quiz_generation'], 10, { supportedSnapshotVersions: ['ai_snapshot_v1'] });
      expect(result.jobs).toHaveLength(2);
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // lockJob
  // ═══════════════════════════════════════════════════════════════════

  describe('lockJob', () => {
    it('locks a pending unlocked job', async () => {
      mockAiRuntimeJob.updateMany.mockResolvedValue({ count: 1 });
      const result = await service.lockJob('j1', 'rt-1');
      expect(result.status).toBe('locked');
      expect(result.jobId).toBe('j1');
      expect(result.lockUntil).toBeGreaterThan(Date.now());
    });

    it('throws ConflictException when job cannot be locked', async () => {
      mockAiRuntimeJob.updateMany.mockResolvedValue({ count: 0 });
      await expect(service.lockJob('j1', 'rt-1')).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'JOB_ALREADY_LOCKED' }),
      });
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // heartbeatJob
  // ═══════════════════════════════════════════════════════════════════

  describe('heartbeatJob', () => {
    it('transitions locked→running on first heartbeat (two-phase updateMany)', async () => {
      mockAiRuntimeJob.updateMany
        .mockResolvedValueOnce({ count: 1 })  // locked→running
        .mockResolvedValueOnce({ count: 0 }); // running→running (no-op)
      mockAiRuntimeJob.findUnique.mockResolvedValue({ cancelRequestedAt: null });

      const result = await service.heartbeatJob('j1', 'rt-1');

      expect(mockAiRuntimeJob.updateMany).toHaveBeenCalledTimes(2);
      // First call: locked→running with startedAt
      expect(mockAiRuntimeJob.updateMany).toHaveBeenNthCalledWith(1,
        expect.objectContaining({
          where: { id: 'j1', lockedBy: 'rt-1', status: 'locked' },
          data: expect.objectContaining({ status: 'running', startedAt: expect.any(Date) }),
        }),
      );
      // Second call: running→running (extend lock only)
      expect(mockAiRuntimeJob.updateMany).toHaveBeenNthCalledWith(2,
        expect.objectContaining({
          where: { id: 'j1', lockedBy: 'rt-1', status: 'running' },
          data: { lockUntil: expect.any(Date) },
        }),
      );
      expect(result.jobId).toBe('j1');
      expect(result.lockUntil).toBeGreaterThan(Date.now());
      expect(result.cancelRequested).toBe(false);
    });

    it('extends lock on running→running subsequent heartbeat', async () => {
      mockAiRuntimeJob.updateMany
        .mockResolvedValueOnce({ count: 0 })  // locked→running (no-op)
        .mockResolvedValueOnce({ count: 1 }); // running→running
      mockAiRuntimeJob.findUnique.mockResolvedValue({ cancelRequestedAt: null });

      const result = await service.heartbeatJob('j1', 'rt-1');

      expect(result.cancelRequested).toBe(false);
    });

    it('throws NotFoundException when neither locked nor running', async () => {
      mockAiRuntimeJob.updateMany
        .mockResolvedValueOnce({ count: 0 })
        .mockResolvedValueOnce({ count: 0 });

      await expect(service.heartbeatJob('j1', 'rt-1')).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }),
      });
    });

    it('returns cancelRequested=true when cancellation was requested', async () => {
      mockAiRuntimeJob.updateMany
        .mockResolvedValueOnce({ count: 1 })
        .mockResolvedValueOnce({ count: 0 });
      mockAiRuntimeJob.findUnique.mockResolvedValue({ cancelRequestedAt: new Date() });

      const result = await service.heartbeatJob('j1', 'rt-1');

      expect(result.cancelRequested).toBe(true);
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // getSnapshot
  // ═══════════════════════════════════════════════════════════════════

  describe('getSnapshot', () => {
    const jobWithSnapshot = { id: 'j1', userId: 'u1', targetType: 'material', targetId: 'm1', snapshotId: 'snap-1' };

    it('returns existing valid snapshot (matching sourceDataVersion + not expired)', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(jobWithSnapshot);
      mockLearningAnalysisSnapshot.findUnique.mockResolvedValue(mockSnapshot);

      const result = await service.getSnapshot('j1');

      expect(result.snapshotId).toBe('snap-1');
      expect(result.snapshotVersion).toBe('ai_snapshot_v1');
      expect(mockSnapshotBuilder.buildSnapshot).not.toHaveBeenCalled();
    });

    it('throws NotFoundException when job not found', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(null);
      await expect(service.getSnapshot('j1')).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }),
      });
    });

    it('rebuilds snapshot when job has no snapshotId', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue({ id: 'j1', userId: 'u1', targetType: 'material', targetId: 'm1', snapshotId: null });
      mockSnapshotBuilder.buildSnapshot.mockResolvedValue(mockSnapshot);
      mockAiRuntimeJob.update.mockResolvedValue({});

      const result = await service.getSnapshot('j1');

      expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalledWith('u1', 'material', 'm1');
      expect(mockAiRuntimeJob.update).toHaveBeenCalledWith(expect.objectContaining({
        where: { id: 'j1' }, data: { snapshotId: 'snap-1' },
      }));
      expect(result.snapshotId).toBe('snap-1');
    });

    it('rebuilds snapshot when existing snapshot has stale sourceDataVersion', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(jobWithSnapshot);
      mockLearningAnalysisSnapshot.findUnique.mockResolvedValue({ ...mockSnapshot, sourceDataVersion: '0.9' });
      mockSnapshotBuilder.buildSnapshot.mockResolvedValue({ ...mockSnapshot, id: 'snap-new' });
      mockAiRuntimeJob.update.mockResolvedValue({});

      const result = await service.getSnapshot('j1');

      expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalled();
      expect(result.snapshotId).toBe('snap-new');
    });

    it('rebuilds snapshot when existing snapshot is expired', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(jobWithSnapshot);
      mockLearningAnalysisSnapshot.findUnique.mockResolvedValue({ ...mockSnapshot, expiresAt: new Date(Date.now() - 3600_000) });
      mockSnapshotBuilder.buildSnapshot.mockResolvedValue({ ...mockSnapshot, id: 'snap-new' });
      mockAiRuntimeJob.update.mockResolvedValue({});

      const result = await service.getSnapshot('j1');

      expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalled();
      expect(result.snapshotId).toBe('snap-new');
    });

    it('returns existing snapshot when expiresAt is null (no expiry)', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(jobWithSnapshot);
      mockLearningAnalysisSnapshot.findUnique.mockResolvedValue({ ...mockSnapshot, expiresAt: null });
      const result = await service.getSnapshot('j1');
      expect(result.snapshotId).toBe('snap-1');
      expect(mockSnapshotBuilder.buildSnapshot).not.toHaveBeenCalled();
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // resolveSnapshot: concurrent race (API-AI-R01)
  // ═══════════════════════════════════════════════════════════════════

  describe('resolveSnapshot concurrent race', () => {
    it('handles concurrent getSnapshot without error (known race, accepted risk)', async () => {
      // Simulate: two calls with no valid snapshot → both trigger buildSnapshot
      const job = { id: 'j1', userId: 'u1', targetType: 'material', targetId: 'm1', snapshotId: null };
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);

      // Each buildSnapshot call returns a new id (simulating two builds)
      mockSnapshotBuilder.buildSnapshot
        .mockResolvedValueOnce({ ...mockSnapshot, id: 'snap-A' })
        .mockResolvedValueOnce({ ...mockSnapshot, id: 'snap-B' });
      // First update writes snap-A, second overwrites with snap-B (the race)
      mockAiRuntimeJob.update.mockResolvedValue({});

      const [r1, r2] = await Promise.all([
        service.getSnapshot('j1'),
        service.getSnapshot('j1'),
      ]);

      // Both calls succeed; one snapshot may be orphaned (documented risk)
      expect(r1.snapshotId).toBeDefined();
      expect(r2.snapshotId).toBeDefined();
      // Two buildSnapshot calls issued (the race)
      expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalledTimes(2);
      // Job updated twice (second overwrites first — orphan snap-A)
      expect(mockAiRuntimeJob.update).toHaveBeenCalledTimes(2);
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // resolveCredential
  // ═══════════════════════════════════════════════════════════════════

  describe('resolveCredential', () => {
    it('resolves credential for user_deepseek_key mode', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue({ userId: 'u1' });
      mockUserAi.resolveCredentialForJob.mockResolvedValue({ provider: 'deepseek', apiKey: 'sk-test' });
      const result = await service.resolveCredential('j1', 'user_deepseek_key', 'deepseek', 'cred-1');
      expect(result.apiKey).toBe('sk-test');
      expect(result.apiKeyMode).toBe('user_deepseek_key');
    });

    it('throws BadRequestException when credentialId missing for user_deepseek_key', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue({ userId: 'u1' });
      await expect(service.resolveCredential('j1', 'user_deepseek_key', 'deepseek')).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'CREDENTIAL_NOT_FOUND' }),
      });
    });

    it('returns empty apiKey for platform_key mode', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue({ userId: 'u1' });
      const result = await service.resolveCredential('j1', 'platform_key', 'deepseek');
      expect(result.apiKey).toBe('');
      expect(result.apiKeyMode).toBe('platform_key');
    });

    it('throws NotFoundException when job not found', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(null);
      await expect(service.resolveCredential('j1', 'platform_key', 'deepseek')).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }),
      });
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // submitResult
  // ═══════════════════════════════════════════════════════════════════

  describe('submitResult', () => {
    const dto = {
      runtimeInstanceId: 'rt-1',
      schemaVersion: 'ov1',
      status: 'succeeded' as const,
      validatedOutput: { learningState: 'good' },
      attemptNo: 1,
      outputHash: 'hash1',
    };

    const job = {
      id: 'j1', userId: 'u1', jobType: 'learning_state_analysis' as const,
      targetType: 'material', targetId: 'm1',
      status: 'running', outputSchemaVersion: 'ov1',
      snapshotId: 'snap-1', promptVersion: 'v1',
    };

    beforeEach(() => {
      mockNotification.create.mockResolvedValue({});
    });

    it('stores result and marks job succeeded with finishedAt', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockAiLearningAnalysis.create.mockResolvedValue({});

      const result = await service.submitResult('j1', dto);

      expect(mockAiRuntimeJob.update).toHaveBeenCalledWith(expect.objectContaining({
        where: { id: 'j1' },
        data: expect.objectContaining({ status: 'succeeded', finishedAt: expect.any(Date) }),
      }));
      expect(result.status).toBe('ok');
      expect(result.duplicate).toBe(false);
    });

    it('throws NotFoundException when job not found', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(null);
      await expect(service.submitResult('j1', dto)).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }),
      });
    });

    it('throws ConflictException when job is not locked or running', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue({ ...job, status: 'succeeded' });
      await expect(service.submitResult('j1', dto)).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'JOB_NOT_ACTIVE' }),
      });
    });

    it('throws BadRequestException when schemaVersion mismatch', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue({ ...job, outputSchemaVersion: 'ov2' });
      await expect(service.submitResult('j1', dto)).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'RESULT_SCHEMA_UNSUPPORTED' }),
      });
    });

    it('returns duplicate=true when same idempotency key exists', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeResult.findFirst.mockResolvedValue({ id: 'r1' });
      const result = await service.submitResult('j1', dto);
      expect(result.duplicate).toBe(true);
      expect(mockAiRuntimeResult.create).not.toHaveBeenCalled();
    });

    it('throws ConflictException when job already has succeeded result', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue({ id: 'r-old', status: 'succeeded' });
      await expect(service.submitResult('j1', dto)).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'RESULT_ALREADY_EXISTS' }),
      });
    });

    it('creates success notification', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockAiLearningAnalysis.create.mockResolvedValue({});

      await service.submitResult('j1', dto);

      expect(mockNotification.create).toHaveBeenCalledWith(expect.objectContaining({
        data: expect.objectContaining({ type: 'ai_job_succeeded' }),
      }));
    });

    it('increments notifyFailures counter when notification fails', async () => {
      const loggerSpy = jest.spyOn((service as any).logger, 'error');
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockAiLearningAnalysis.create.mockResolvedValue({});
      mockNotification.create.mockRejectedValue(new Error('DB down'));

      await service.submitResult('j1', dto);
      // Flush pending microtasks so fire-and-forget .catch() runs
      await new Promise(r => setTimeout(r, 0));

      expect(RuntimeInternalService.notifyFailures).toBeGreaterThanOrEqual(1);
      expect(loggerSpy).toHaveBeenCalled();
    });

    it('increments persistResultFailures counter when persistence fails', async () => {
      const loggerSpy = jest.spyOn((service as any).logger, 'error');
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockAiLearningAnalysis.create.mockRejectedValue(new Error('DB down'));

      await service.submitResult('j1', dto);

      expect(RuntimeInternalService.persistResultFailures).toBeGreaterThanOrEqual(1);
      expect(loggerSpy).toHaveBeenCalled();
    });

    // ── persistResult: job type routing ──

    it('persists learning_state_analysis output', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockAiLearningAnalysis.create.mockResolvedValue({});

      await service.submitResult('j1', dto);

      expect(mockAiLearningAnalysis.create).toHaveBeenCalledWith(expect.objectContaining({
        data: expect.objectContaining({ userId: 'u1', jobId: 'j1', learningState: 'good' }),
      }));
    });

    it('persists weak_point_analysis output and resolves prior active candidates', async () => {
      const wpDto = { ...dto, validatedOutput: { candidates: [{ knowledgePointId: 'kp1', title: 'Weak Spot' }] } };
      const wpJob = { ...job, jobType: 'weak_point_analysis' as const };
      mockAiRuntimeJob.findUnique.mockResolvedValue(wpJob);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockWeakPointCandidate.updateMany.mockResolvedValue({ count: 1 });
      mockWeakPointCandidate.create.mockResolvedValue({});

      await service.submitResult('j1', wpDto);

      expect(mockWeakPointCandidate.updateMany).toHaveBeenCalledWith(expect.objectContaining({
        where: { userId: 'u1', targetType: 'material', targetId: 'm1', status: 'active' },
        data: { status: 'resolved' },
      }));
      expect(mockWeakPointCandidate.create).toHaveBeenCalledTimes(1);
    });

    it('persists next_action_planning output', async () => {
      const naDto = { ...dto, validatedOutput: { actions: [{ actionType: 'review', title: 'Review Ch1' }] } };
      const naJob = { ...job, jobType: 'next_action_planning' as const };
      mockAiRuntimeJob.findUnique.mockResolvedValue(naJob);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockNextActionRecommendation.updateMany.mockResolvedValue({ count: 1 });
      mockNextActionRecommendation.create.mockResolvedValue({});

      await service.submitResult('j1', naDto);

      expect(mockNextActionRecommendation.create).toHaveBeenCalledWith(expect.objectContaining({
        data: expect.objectContaining({ title: 'Review Ch1', status: 'active' }),
      }));
    });

    it('persists quiz_generation output (creates quiz + questions)', async () => {
      const qDto = { ...dto, validatedOutput: { questions: [{ type: 'choice', stem: 'Q1?', answer: 'A1' }] } };
      const qJob = { ...job, jobType: 'quiz_generation' as const, targetType: 'knowledge_base' as const, targetId: 'kb1' };
      mockAiRuntimeJob.findUnique.mockResolvedValue(qJob);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockQuestionGenerationPlan.updateMany.mockResolvedValue({ count: 1 });
      mockQuiz.create.mockResolvedValue({ id: 'quiz-1' });
      mockQuizQuestion.findMany.mockResolvedValue([]);
      mockQuizQuestion.create.mockResolvedValue({});

      await service.submitResult('j1', qDto);

      expect(mockQuiz.create).toHaveBeenCalled();
      expect(mockQuizQuestion.create).toHaveBeenCalledTimes(1);
    });

    it('persists flashcard_generation output', async () => {
      const fcDto = { ...dto, validatedOutput: { cards: [{ front: 'Front', back: 'Back' }] } };
      const fcJob = { ...job, jobType: 'flashcard_generation' as const };
      mockAiRuntimeJob.findUnique.mockResolvedValue(fcJob);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockFlashcardGenerationPlan.updateMany.mockResolvedValue({ count: 1 });
      mockFlashcard.findMany.mockResolvedValue([]);
      mockFlashcard.create.mockResolvedValue({});

      await service.submitResult('j1', fcDto);

      expect(mockFlashcardGenerationPlan.updateMany).toHaveBeenCalled();
      expect(mockFlashcard.create).toHaveBeenCalledTimes(1);
    });

    it('skips persistResult when validatedOutput is empty', async () => {
      const noOutputDto = { ...dto, validatedOutput: undefined };
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});

      await service.submitResult('j1', noOutputDto);

      expect(mockAiLearningAnalysis.create).not.toHaveBeenCalled();
    });

    // ── validateOutput warnings (via submitResult) ──

    it('logs warning on missing learningState for learning_state_analysis', async () => {
      const loggerSpy = jest.spyOn((service as any).logger, 'warn');
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockAiLearningAnalysis.create.mockResolvedValue({});

      await service.submitResult('j1', { ...dto, validatedOutput: { summary: 'no learningState' } });

      expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('missing learningState'));
    });

    it('logs warning on confidence out of range [0,1]', async () => {
      const loggerSpy = jest.spyOn((service as any).logger, 'warn');
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockAiLearningAnalysis.create.mockResolvedValue({});

      await service.submitResult('j1', { ...dto, validatedOutput: { learningState: 'good', confidence: 1.5 } });

      expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('confidence out of range'));
    });

    it('logs warning when weak_point_analysis has no candidates', async () => {
      const loggerSpy = jest.spyOn((service as any).logger, 'warn');
      const wpJob = { ...job, jobType: 'weak_point_analysis' as const };
      mockAiRuntimeJob.findUnique.mockResolvedValue(wpJob);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});

      await service.submitResult('j1', { ...dto, validatedOutput: { candidates: [] } });

      expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('no candidates'));
    });

    it('logs warning when next_action_planning has no actions', async () => {
      const loggerSpy = jest.spyOn((service as any).logger, 'warn');
      const naJob = { ...job, jobType: 'next_action_planning' as const };
      mockAiRuntimeJob.findUnique.mockResolvedValue(naJob);
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});

      await service.submitResult('j1', { ...dto, validatedOutput: { actions: [] } });

      expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('no actions'));
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // submitFailure
  // ═══════════════════════════════════════════════════════════════════

  describe('submitFailure', () => {
    const job = {
      id: 'j1', userId: 'u1', jobType: 'quiz_generation' as const,
      status: 'running' as const, retryCount: 0, maxRetryCount: 3,
    };

    it('handles JOB_CANCELLED error code', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeJob.update.mockResolvedValue({});

      const result = await service.submitFailure('j1', {
        runtimeInstanceId: 'rt-1', errorCode: 'JOB_CANCELLED', errorMessage: 'Cancelled', retryable: false,
      });

      expect(mockAiRuntimeJob.update).toHaveBeenCalledWith(expect.objectContaining({
        where: { id: 'j1' },
        data: expect.objectContaining({ status: 'cancelled', cancelledAt: expect.any(Date) }),
      }));
      expect(result.status).toBe('cancelled');
    });

    it('re-enqueues as pending when retryable and not exceeded', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeJob.update.mockResolvedValue({});

      const result = await service.submitFailure('j1', {
        runtimeInstanceId: 'rt-1', errorCode: 'MODEL_TIMEOUT', errorMessage: 'Timeout', retryable: true,
      });

      expect(mockAiRuntimeJob.update).toHaveBeenCalledWith(expect.objectContaining({
        where: { id: 'j1' },
        data: expect.objectContaining({ status: 'pending', lockedBy: null, retryCount: 1 }),
      }));
      expect(result.status).toBe('pending');
    });

    it('marks failed when retryable but retries exceeded', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue({ ...job, retryCount: 3 });
      mockAiRuntimeJob.update.mockResolvedValue({});

      const result = await service.submitFailure('j1', {
        runtimeInstanceId: 'rt-1', errorCode: 'MODEL_TIMEOUT', errorMessage: 'Timeout', retryable: true,
      });

      expect(mockAiRuntimeJob.update).toHaveBeenCalledWith(expect.objectContaining({
        where: { id: 'j1' },
        data: expect.objectContaining({ status: 'failed', finishedAt: expect.any(Date), retryCount: 4 }),
      }));
      expect(result.status).toBe('failed');
    });

    it('marks failed when not retryable (DB update)', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockAiRuntimeJob.update.mockResolvedValue({});

      await service.submitFailure('j1', {
        runtimeInstanceId: 'rt-1', errorCode: 'INVALID_OUTPUT', errorMessage: 'Bad JSON', retryable: false,
      });

      expect(mockAiRuntimeJob.update).toHaveBeenCalledWith(expect.objectContaining({
        where: { id: 'j1' },
        data: expect.objectContaining({ status: 'failed' }),
      }));
    });

    it('creates failure notification when retries exceeded', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue({ ...job, retryCount: 3 });
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockNotification.create.mockResolvedValue({});

      await service.submitFailure('j1', {
        runtimeInstanceId: 'rt-1', errorCode: 'MODEL_TIMEOUT', errorMessage: 'Timeout', retryable: true,
      });

      expect(mockNotification.create).toHaveBeenCalledWith(expect.objectContaining({
        data: expect.objectContaining({ type: 'ai_job_failed' }),
      }));
    });

    it('throws NotFoundException when job not found', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(null);
      await expect(service.submitFailure('j1', {
        runtimeInstanceId: 'rt-1', errorCode: 'ERR', errorMessage: 'x', retryable: false,
      })).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'JOB_NOT_FOUND' }),
      });
    });

    it('throws ConflictException when job is not active', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue({ ...job, status: 'succeeded' });
      await expect(service.submitFailure('j1', {
        runtimeInstanceId: 'rt-1', errorCode: 'ERR', errorMessage: 'x', retryable: false,
      })).rejects.toMatchObject({
        response: expect.objectContaining({ errorCode: 'JOB_NOT_ACTIVE' }),
      });
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // submitInvocationLogs
  // ═══════════════════════════════════════════════════════════════════

  describe('submitInvocationLogs', () => {
    const logEntry = {
      jobId: 'j1', provider: 'deepseek', model: 'deepseek-chat',
      apiKeyMode: 'platform_key', promptName: 'quiz_gen',
      promptVersion: 'v1', outputSchemaVersion: 'ov1',
      inputTokens: 100, outputTokens: 200, totalTokens: 300,
      latencyMs: 1500, success: true,
      retryCount: 0, runtimeInstanceId: 'rt-1',
    };

    it('creates invocation logs for valid jobs', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue({ userId: 'u1' });
      mockModelInvocationLog.create.mockResolvedValue({});
      const result = await service.submitInvocationLogs([logEntry]);
      expect(mockModelInvocationLog.create).toHaveBeenCalledTimes(1);
      expect(result.accepted).toBe(1);
    });

    it('skips logs for non-existent jobs', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(null);
      const result = await service.submitInvocationLogs([logEntry]);
      expect(mockModelInvocationLog.create).not.toHaveBeenCalled();
      expect(result.accepted).toBe(0);
    });

    it('handles mixed valid and invalid job logs', async () => {
      mockAiRuntimeJob.findUnique
        .mockResolvedValueOnce({ userId: 'u1' })
        .mockResolvedValueOnce(null);
      mockModelInvocationLog.create.mockResolvedValue({});
      const result = await service.submitInvocationLogs([logEntry, { ...logEntry, jobId: 'j2' }]);
      expect(mockModelInvocationLog.create).toHaveBeenCalledTimes(1);
      expect(result.accepted).toBe(1);
    });

    it('returns accepted=0 for empty logs array', async () => {
      const result = await service.submitInvocationLogs([]);
      expect(result.accepted).toBe(0);
      expect(mockModelInvocationLog.create).not.toHaveBeenCalled();
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // resolveSnapshot (via getSnapshot)
  // ═══════════════════════════════════════════════════════════════════

  describe('resolveSnapshot (via getSnapshot)', () => {
    const job = { id: 'j1', userId: 'u1', targetType: 'material', targetId: 'm1', snapshotId: 'snap-old' };

    it('rebuilds when existing snapshot has both wrong version and expired', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockLearningAnalysisSnapshot.findUnique.mockResolvedValue({
        ...mockSnapshot, id: 'snap-old', sourceDataVersion: '0.8',
        expiresAt: new Date(Date.now() - 3600_000),
      });
      mockSnapshotBuilder.buildSnapshot.mockResolvedValue({ ...mockSnapshot, id: 'snap-new' });
      mockAiRuntimeJob.update.mockResolvedValue({});

      const result = await service.getSnapshot('j1');

      expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalled();
      expect(result.snapshotId).toBe('snap-new');
    });

    it('rebuilds when snapshot does not exist in DB', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(job);
      mockLearningAnalysisSnapshot.findUnique.mockResolvedValue(null);
      mockSnapshotBuilder.buildSnapshot.mockResolvedValue(mockSnapshot);
      mockAiRuntimeJob.update.mockResolvedValue({});

      const result = await service.getSnapshot('j1');

      expect(mockSnapshotBuilder.buildSnapshot).toHaveBeenCalledWith('u1', 'material', 'm1');
      expect(result.snapshotId).toBe('snap-1');
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // convertQuizCandidates (via submitResult)
  // ═══════════════════════════════════════════════════════════════════

  describe('convertQuizCandidates (via submitResult)', () => {
    const qJob = {
      id: 'j1', userId: 'u1', jobType: 'quiz_generation' as const,
      targetType: 'knowledge_base' as const, targetId: 'kb1',
      status: 'running' as const, outputSchemaVersion: 'ov1',
      snapshotId: 'snap-1', promptVersion: 'v1',
    };

    beforeEach(() => {
      mockNotification.create.mockResolvedValue({});
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockQuestionGenerationPlan.updateMany.mockResolvedValue({ count: 1 });
      mockQuiz.create.mockResolvedValue({ id: 'quiz-1' });
      mockQuizQuestion.findMany.mockResolvedValue([]);
      mockQuizQuestion.create.mockResolvedValue({});
    });

    it('skips questions missing stem or answer', async () => {
      const loggerSpy = jest.spyOn((service as any).logger, 'warn');
      mockAiRuntimeJob.findUnique.mockResolvedValue(qJob);

      await service.submitResult('j1', {
        runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded',
        validatedOutput: { questions: [{ type: 'choice', stem: 'Valid', answer: 'A' }, { stem: '', answer: '' }] },
        attemptNo: 1, outputHash: 'h1',
      });

      expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('skipped 1 question'));
    });

    it('returns early when all questions are invalid', async () => {
      mockAiRuntimeJob.findUnique.mockResolvedValue(qJob);

      await service.submitResult('j1', {
        runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded',
        validatedOutput: { questions: [{ type: 'choice', stem: '', answer: '' }] },
        attemptNo: 1, outputHash: 'h2',
      });

      expect(mockQuiz.create).not.toHaveBeenCalled();
    });

    it('skips duplicate questions (same stem for user)', async () => {
      const loggerSpy = jest.spyOn((service as any).logger, 'warn');
      mockAiRuntimeJob.findUnique.mockResolvedValue(qJob);
      // Batch dedup: findMany returns existing stems; 'Duplicate?' is duplicate, 'Unique?' is new
      mockQuizQuestion.findMany.mockResolvedValue([{ stem: 'Duplicate?' }]);
      mockQuiz.update.mockResolvedValue({});

      await service.submitResult('j1', {
        runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded',
        validatedOutput: {
          questions: [
            { type: 'choice', stem: 'Duplicate?', answer: 'A' },
            { type: 'choice', stem: 'Unique?', answer: 'B' },
          ],
        },
        attemptNo: 1, outputHash: 'h3',
      });

      expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('skipping duplicate question'));
      expect(mockQuiz.update).toHaveBeenCalledWith(expect.objectContaining({
        where: { id: 'quiz-1' }, data: { questionCount: 1 },
      }));
    });

    it('does not create quiz when targetType is not knowledge_base', async () => {
      const nonKbJob = { ...qJob, targetType: 'material' as const };
      mockAiRuntimeJob.findUnique.mockResolvedValue(nonKbJob);

      await service.submitResult('j1', {
        runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded',
        validatedOutput: { questions: [{ type: 'choice', stem: 'Q?', answer: 'A' }] },
        attemptNo: 1, outputHash: 'h4',
      });

      expect(mockQuiz.create).not.toHaveBeenCalled();
    });
  });

  // ═══════════════════════════════════════════════════════════════════
  // convertFlashcardCandidates (via submitResult)
  // ═══════════════════════════════════════════════════════════════════

  describe('convertFlashcardCandidates (via submitResult)', () => {
    const fcJob = {
      id: 'j1', userId: 'u1', jobType: 'flashcard_generation' as const,
      targetType: 'material' as const, targetId: 'm1',
      status: 'running' as const, outputSchemaVersion: 'ov1',
      snapshotId: 'snap-1', promptVersion: 'v1',
    };

    beforeEach(() => {
      mockNotification.create.mockResolvedValue({});
      mockAiRuntimeResult.findFirst.mockResolvedValue(null);
      mockAiRuntimeResult.findUnique.mockResolvedValue(null);
      mockAiRuntimeResult.create.mockResolvedValue({});
      mockAiRuntimeJob.update.mockResolvedValue({});
      mockFlashcardGenerationPlan.updateMany.mockResolvedValue({ count: 1 });
      mockFlashcard.findMany.mockResolvedValue([]);
      mockFlashcard.create.mockResolvedValue({});
    });

    it('skips cards missing front or back', async () => {
      const loggerSpy = jest.spyOn((service as any).logger, 'warn');
      mockAiRuntimeJob.findUnique.mockResolvedValue(fcJob);

      await service.submitResult('j1', {
        runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded',
        validatedOutput: { cards: [{ front: 'F', back: 'B' }, { front: '', back: '' }] },
        attemptNo: 1, outputHash: 'h1',
      });

      expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('skipped 1 card'));
    });

    it('skips duplicate cards (same front for user)', async () => {
      const loggerSpy = jest.spyOn((service as any).logger, 'warn');
      mockAiRuntimeJob.findUnique.mockResolvedValue(fcJob);
      // Batch dedup: findMany returns existing fronts; 'Dup' is duplicate, 'Unique' is new
      mockFlashcard.findMany.mockResolvedValue([{ front: 'Dup' }]);

      await service.submitResult('j1', {
        runtimeInstanceId: 'rt-1', schemaVersion: 'ov1', status: 'succeeded',
        validatedOutput: {
          cards: [
            { front: 'Dup', back: 'B1' },
            { front: 'Unique', back: 'B2' },
          ],
        },
        attemptNo: 1, outputHash: 'h2',
      });

      expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('skipping duplicate'));
    });
  });
});
