import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { UserAiService } from './user-ai.service';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { CredentialEncryptionService } from './credential-encryption.service';
import { SnapshotBuilderService } from './snapshot-builder.service';
import { PriorityRulesService } from './priority-rules.service';
import { UserAiQuotaService } from './user-ai-quota.service';
import { PlatformBudgetService } from './platform-budget.service';

describe('UserAiService.createAnalysisJob', () => {
  let service: UserAiService;
  let prisma: any;
  let snapshotBuilder: any;
  let priorityRules: any;
  let quota: any;
  let budget: any;

  const mockSnapshot = { id: 'snap-1', snapshotVersion: 'ai_snapshot_v1' };

  beforeEach(async () => {
    prisma = {
      userAiSettings: { findUnique: jest.fn(), create: jest.fn() },
      userModelCredential: { findFirst: jest.fn() },
      aiRuntimeJob: { findUnique: jest.fn(), create: jest.fn() },
      questionGenerationPlan: { create: jest.fn() },
      flashcardGenerationPlan: { create: jest.fn() },
      userLearningProfile: { findUnique: jest.fn() },
    };
    snapshotBuilder = { buildSnapshot: jest.fn().mockResolvedValue(mockSnapshot) };
    priorityRules = { computeJobPriority: jest.fn().mockReturnValue(50) };
    quota = { checkQuota: jest.fn().mockResolvedValue(undefined), incrementJobCount: jest.fn().mockResolvedValue(undefined) };
    budget = { checkPlatformBudget: jest.fn().mockResolvedValue(undefined) };
    const crypto = { encrypt: jest.fn(), decrypt: jest.fn(), hash: jest.fn(), mask: jest.fn() } as any;

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserAiService,
        { provide: PrismaService, useValue: prisma },
        { provide: CredentialEncryptionService, useValue: crypto },
        { provide: SnapshotBuilderService, useValue: snapshotBuilder },
        { provide: PriorityRulesService, useValue: priorityRules },
        { provide: UserAiQuotaService, useValue: quota },
        { provide: PlatformBudgetService, useValue: budget },
        { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('https://api.deepseek.com') } },
      ],
    }).compile();

    service = module.get(UserAiService);
  });

  const validDto = {
    jobType: 'learning_state_analysis',
    targetType: 'material',
    targetId: 'm1',
  };

  it('creates a job with snapshot and correct fields', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date('2026-06-17') });

    const result = await service.createAnalysisJob('u1', validDto);

    expect(result.jobId).toBe('job-1');
    expect(result.status).toBe('pending');
    expect(snapshotBuilder.buildSnapshot).toHaveBeenCalledWith('u1', 'material', 'm1');
    expect(prisma.aiRuntimeJob.create).toHaveBeenCalledWith(expect.objectContaining({
      data: expect.objectContaining({
        userId: 'u1',
        jobType: 'learning_state_analysis',
        snapshotId: 'snap-1',
        priority: 50,
        promptVersion: 'learning_state_v1',
        outputSchemaVersion: 'analysis_output_v1',
      }),
    }));
  });

  it('auto-creates settings for new user', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue(null);
    prisma.userAiSettings.create.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });

    await service.createAnalysisJob('u1', validDto);

    expect(prisma.userAiSettings.create).toHaveBeenCalledWith({ data: { userId: 'u1' } });
  });

  it('throws AI_ANALYSIS_DISABLED when user opted out', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: false, apiKeyMode: 'platform_key' });

    await expect(service.createAnalysisJob('u1', validDto)).rejects.toThrow(BadRequestException);
    await expect(service.createAnalysisJob('u1', validDto)).rejects.toMatchObject({
      response: { errorCode: 'AI_ANALYSIS_DISABLED' },
    });
  });

  it('returns existing job on idempotent retry', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.aiRuntimeJob.findUnique.mockResolvedValue({ id: 'existing-job', status: 'pending', createdAt: new Date('2026-06-01') });

    const result = await service.createAnalysisJob('u1', { ...validDto, idempotencyKey: 'ik-1' });

    expect(result.jobId).toBe('existing-job');
    expect(snapshotBuilder.buildSnapshot).not.toHaveBeenCalled();
  });

  it('throws INVALID_JOB_TYPE for unknown job type', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key' });

    await expect(service.createAnalysisJob('u1', { ...validDto, jobType: 'invalid_type' }))
      .rejects.toMatchObject({ response: { errorCode: 'INVALID_JOB_TYPE' } });
  });

  it('throws CREDENTIAL_REQUIRED for user key mode without credential', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'user_deepseek_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });

    await expect(service.createAnalysisJob('u1', { ...validDto, apiKeyMode: 'user_deepseek_key' }))
      .rejects.toMatchObject({ response: { errorCode: 'CREDENTIAL_REQUIRED' } });
  });

  it('throws CREDENTIAL_NOT_FOUND for invalid credential', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'user_deepseek_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.userModelCredential.findFirst.mockResolvedValue(null);

    await expect(service.createAnalysisJob('u1', { ...validDto, apiKeyMode: 'user_deepseek_key', credentialId: 'bad-cred' }))
      .rejects.toThrow(NotFoundException);
  });

  it('calls quota check and budget for platform_key', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });

    await service.createAnalysisJob('u1', validDto);

    expect(quota.checkQuota).toHaveBeenCalledWith('u1', 'platform_key');
    expect(budget.checkPlatformBudget).toHaveBeenCalledWith('deepseek', 'deepseek-chat');
    expect(quota.incrementJobCount).toHaveBeenCalledWith('u1', 'platform_key');
  });

  it('skips budget check for user_deepseek_key mode', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'user_deepseek_key', defaultCredentialId: 'c1', fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.userModelCredential.findFirst.mockResolvedValue({ id: 'c1', userId: 'u1', status: 'active' });
    prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });

    await service.createAnalysisJob('u1', { ...validDto });

    expect(budget.checkPlatformBudget).not.toHaveBeenCalled();
  });

  it('computes priority from profile and settings', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.userLearningProfile.findUnique.mockResolvedValue({ qualityPreference: 'exam', examTarget: 'AWS' });
    prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
    priorityRules.computeJobPriority.mockReturnValue(0);

    await service.createAnalysisJob('u1', validDto);

    expect(priorityRules.computeJobPriority).toHaveBeenCalled();
    expect(prisma.aiRuntimeJob.create).toHaveBeenCalledWith(expect.objectContaining({
      data: expect.objectContaining({ priority: 0 }),
    }));
  });

  // ── quiz_generation (API-AI-023) ──

  const quizDto = {
    jobType: 'quiz_generation',
    targetType: 'knowledge_base',
    targetId: 'kb1',
    questionCount: 10,
    difficultyLevel: 'medium',
    questionTypes: ['choice', 'judge'],
    knowledgePointIds: ['kp1', 'kp2'],
  };

  it('creates QuestionGenerationPlan for quiz_generation', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
    prisma.questionGenerationPlan.create.mockResolvedValue({ id: 'plan-1' });

    const result = await service.createAnalysisJob('u1', quizDto);

    expect(prisma.questionGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({
      data: expect.objectContaining({
        jobId: 'job-1',
        count: 10,
        difficultyLevel: 'medium',
        questionTypes: ['choice', 'judge'],
        knowledgePointIds: ['kp1', 'kp2'],
        status: 'pending',
      }),
    }));
    expect(result).toMatchObject({ jobId: 'job-1', planId: 'plan-1' });
  });

  it('rejects quiz_generation with non-knowledge_base target', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key' });

    await expect(service.createAnalysisJob('u1', { ...quizDto, targetType: 'material' }))
      .rejects.toMatchObject({ response: { errorCode: 'INVALID_TARGET_TYPE' } });
  });

  it('uses default questionCount=5 when not provided', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
    prisma.questionGenerationPlan.create.mockResolvedValue({ id: 'plan-1' });

    await service.createAnalysisJob('u1', { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1' });

    expect(prisma.questionGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({
      data: expect.objectContaining({ count: 5, questionTypes: [], knowledgePointIds: [] }),
    }));
  });

  it('does not create questionGenerationPlan for non-quiz job types', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });

    await service.createAnalysisJob('u1', validDto);

    expect(prisma.questionGenerationPlan.create).not.toHaveBeenCalled();
  });

  // ── flashcard_generation (API-AI-024) ──

  const flashcardDto = {
    jobType: 'flashcard_generation',
    targetType: 'knowledge_base',
    targetId: 'kb1',
    cardCount: 20,
    difficultyLevel: 'hard',
    knowledgePointIds: ['kp1'],
  };

  it('creates FlashcardGenerationPlan for flashcard_generation', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
    prisma.flashcardGenerationPlan.create.mockResolvedValue({ id: 'fplan-1' });

    const result = await service.createAnalysisJob('u1', flashcardDto);

    expect(prisma.flashcardGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({
      data: expect.objectContaining({ jobId: 'job-1', count: 20, difficultyLevel: 'hard', knowledgePointIds: ['kp1'], status: 'pending' }),
    }));
    expect(result).toMatchObject({ jobId: 'job-1', planId: 'fplan-1' });
  });

  it('rejects flashcard_generation with non-knowledge_base target', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key' });

    await expect(service.createAnalysisJob('u1', { ...flashcardDto, targetType: 'material' }))
      .rejects.toMatchObject({ response: { errorCode: 'INVALID_TARGET_TYPE' } });
  });

  it('defaults cardCount to 5', async () => {
    prisma.userAiSettings.findUnique.mockResolvedValue({ userId: 'u1', allowAiAnalysis: true, apiKeyMode: 'platform_key', defaultCredentialId: null, fallbackToPlatformKey: true, maxDailyAiJobs: 20, maxDailyTokenBudget: 100000 });
    prisma.aiRuntimeJob.create.mockResolvedValue({ id: 'job-1', status: 'pending', createdAt: new Date() });
    prisma.flashcardGenerationPlan.create.mockResolvedValue({ id: 'fplan-1' });

    await service.createAnalysisJob('u1', { jobType: 'flashcard_generation', targetType: 'knowledge_base', targetId: 'kb1' });

    expect(prisma.flashcardGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({
      data: expect.objectContaining({ count: 5 }),
    }));
  });
});

// ═══════════════════════════════════════════════
// getQuizQuestions（M-QUIZ-01-01）
// ═══════════════════════════════════════════════

describe('UserAiService.getQuizQuestions', () => {
  let service: UserAiService;
  let prisma: any;

  beforeEach(async () => {
    prisma = {
      quiz: {
        findFirst: jest.fn(),
        findMany: jest.fn(),
      },
      quizQuestion: {
        findMany: jest.fn(),
      },
      quizAttempt: {
        findFirst: jest.fn(),
      },
      userAiSettings: { findUnique: jest.fn() },
      userModelCredential: { findFirst: jest.fn() },
      aiRuntimeJob: { findUnique: jest.fn(), create: jest.fn() },
      questionGenerationPlan: { create: jest.fn() },
      flashcardGenerationPlan: { create: jest.fn() },
      userLearningProfile: { findUnique: jest.fn() },
      aiLearningAnalysis: { findFirst: jest.fn(), findMany: jest.fn() },
      weakPointCandidate: { findMany: jest.fn() },
      nextActionRecommendation: { findMany: jest.fn() },
      quizAnswer: { findMany: jest.fn() },
      aiJobArtifact: { findFirst: jest.fn(), findMany: jest.fn() },
    };
    const snapshotBuilder = { buildSnapshot: jest.fn().mockResolvedValue({ id: 'snap-1' }) };
    const priorityRules = { computeJobPriority: jest.fn().mockReturnValue(50) };
    const quota = { checkQuota: jest.fn(), incrementJobCount: jest.fn() };
    const budget = { checkPlatformBudget: jest.fn() };
    const crypto = { encrypt: jest.fn(), decrypt: jest.fn(), hash: jest.fn(), mask: jest.fn() } as any;
    const configService = { get: jest.fn().mockReturnValue('https://api.deepseek.com') } as any;

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserAiService,
        { provide: PrismaService, useValue: prisma },
        { provide: CredentialEncryptionService, useValue: crypto },
        { provide: SnapshotBuilderService, useValue: snapshotBuilder },
        { provide: PriorityRulesService, useValue: priorityRules },
        { provide: UserAiQuotaService, useValue: quota },
        { provide: PlatformBudgetService, useValue: budget },
        { provide: ConfigService, useValue: configService },
      ],
    }).compile();

    service = module.get(UserAiService);
  });

  const fullQuestions = [
    { id: 'qq1', type: 'choice', stem: 'Q1?', options: ['A', 'B'], answer: '0', explanation: 'Because', sourceBlockIds: null, orderIndex: 0 },
  ];

  // Mock findMany to simulate Prisma select behavior
  function mockFindMany() {
    prisma.quizQuestion.findMany.mockImplementation((args: any) => {
      const select = args?.select ?? {};
      return fullQuestions.map(q => {
        const filtered: any = {};
        for (const key of Object.keys(select)) {
          if (select[key] === true) filtered[key] = (q as any)[key];
        }
        return filtered;
      });
    });
  }

  beforeEach(() => {
    mockFindMany();
  });

  it('未提供 attemptId → 不返回 answer/explanation（安全默认）', async () => {
    prisma.quiz.findFirst.mockResolvedValue({ id: 'q1' });

    const result = await service.getQuizQuestions('u1', 'q1');

    for (const q of result) {
      expect(q.answer).toBeUndefined();
      expect(q.explanation).toBeUndefined();
    }
    expect(prisma.quizAttempt.findFirst).not.toHaveBeenCalled();
  });

  it('提供已完成 attemptId → 返回 answer/explanation', async () => {
    prisma.quiz.findFirst.mockResolvedValue({ id: 'q1' });
    prisma.quizAttempt.findFirst.mockResolvedValue({ finishedAt: new Date() });

    const result = await service.getQuizQuestions('u1', 'q1', 'a1');

    for (const q of result) {
      expect(q.answer).toBeDefined();
      expect(q.explanation).toBeDefined();
    }
    // B1 修复：attempt 查询必须包含 quizId
    expect(prisma.quizAttempt.findFirst).toHaveBeenCalledWith({
      where: { id: 'a1', userId: 'u1', quizId: 'q1' },
      select: { finishedAt: true },
    });
  });

  it('提供未完成 attemptId → 不返回 answer/explanation', async () => {
    prisma.quiz.findFirst.mockResolvedValue({ id: 'q1' });
    prisma.quizAttempt.findFirst.mockResolvedValue({ finishedAt: null });

    const result = await service.getQuizQuestions('u1', 'q1', 'a1');

    for (const q of result) {
      expect(q.answer).toBeUndefined();
      expect(q.explanation).toBeUndefined();
    }
  });

  it('B1：用 Quiz B 的 attemptId 查 Quiz A 的题目 → 不泄漏答案', async () => {
    // 用户有 Quiz A 的题目，attempt-b 属于 Quiz B 且已完成
    prisma.quiz.findFirst.mockResolvedValue({ id: 'quiz-a' });
    // B1 修复：查询条件包含 quizId，attempt-b 的 quizId 是 quiz-b，不匹配
    prisma.quizAttempt.findFirst.mockResolvedValue(null);

    const result = await service.getQuizQuestions('u1', 'quiz-a', 'attempt-b');

    // attempt 不属于该 quiz → 不应返回答案
    for (const q of result) {
      expect(q.answer).toBeUndefined();
      expect(q.explanation).toBeUndefined();
    }
    // 验证查询包含了 quizId
    expect(prisma.quizAttempt.findFirst).toHaveBeenCalledWith({
      where: { id: 'attempt-b', userId: 'u1', quizId: 'quiz-a' },
      select: { finishedAt: true },
    });
  });

  it('不存在的 quizId → NotFoundException', async () => {
    prisma.quiz.findFirst.mockResolvedValue(null);
    await expect(service.getQuizQuestions('u1', 'nx')).rejects.toThrow(NotFoundException);
  });
});
