import { describe, it, expect, vi } from 'vitest';
import {
  buildCoachMessages,
  normalizeCoachResult,
  runPromptCoach,
  COACH_LIMITS,
  type PromptCoachDeps,
} from './prompt-coach.js';

function makeDeps(over: Partial<PromptCoachDeps> = {}): PromptCoachDeps {
  return {
    userFolderRoot: '/data/users',
    pieceCatalog: {
      getForUser: () => [
        { name: 'chat', description: '汎用の会話・調査タスク' },
        { name: 'research', description: 'Web を調べてレポートにまとめる' },
      ],
    },
    skillCatalog: {
      getForUser: () => [
        { name: 'pdf-fill', description: 'PDF フォームに記入する' },
      ],
    },
    readMemory: () => '- [Foo](foo.md) — ユーザーは日本語で回答を好む',
    readAgents: () => 'AGENTS: 常に結論から書く',
    callLlm: vi.fn(),
    ...over,
  };
}

const REQ = { instruction: 'PDF を読んで要約して', piece: undefined, userId: 'u1' };

describe('buildCoachMessages', () => {
  it('全ソースが揃っている時はそれぞれをプロンプトに含める', () => {
    const { system, user, pieceCandidates } = buildCoachMessages(makeDeps(), REQ);
    expect(system).toContain('日本語');
    expect(user).toContain('PDF を読んで要約して');
    // context sources
    expect(user).toContain('ユーザーは日本語で回答を好む'); // memory
    expect(user).toContain('常に結論から書く'); // agents
    expect(user).toContain('pdf-fill'); // skills
    expect(user).toContain('research'); // pieces
    expect(pieceCandidates).toEqual(['chat', 'research']);
  });

  it('各ソースが欠損(null/空)でも例外なく組み立てる', () => {
    const deps = makeDeps({
      readMemory: () => null,
      readAgents: () => null,
      skillCatalog: { getForUser: () => [] },
      pieceCatalog: { getForUser: () => [] },
    });
    const { user, pieceCandidates } = buildCoachMessages(deps, REQ);
    expect(user).toContain('PDF を読んで要約して');
    expect(pieceCandidates).toEqual([]);
  });

  it('巨大なソースは上限で切り詰める', () => {
    const huge = 'あ'.repeat(COACH_LIMITS.memoryChars + 5000);
    const deps = makeDeps({ readMemory: () => huge });
    const { user } = buildCoachMessages(deps, REQ);
    // memory section should not carry the full oversized blob
    expect(user.length).toBeLessThan(huge.length);
  });

  it('下書き本文も上限で切り詰める', () => {
    const huge = 'x'.repeat(COACH_LIMITS.instructionChars + 5000);
    const { user } = buildCoachMessages(makeDeps(), { ...REQ, instruction: huge });
    expect(user.length).toBeLessThan(huge.length + 2000);
  });
});

describe('normalizeCoachResult', () => {
  const candidates = ['chat', 'research'];

  it('スコアを範囲内にクランプし、配列を既定値で埋める', () => {
    const r = normalizeCoachResult(
      {
        overall: 250,
        axes: [{ name: '明確さ', score: 99, comment: 'ok' }],
        rewrite: '改善案',
        predicted_piece: { name: 'research', reason: 'Web 調査だから' },
        maestro_tips: [{ feature: '添付', suggestion: 'PDF を添付できます' }],
        personalized: ['既にスキル所有'],
      },
      candidates,
    );
    expect(r.overall).toBe(100);
    expect(r.axes[0].score).toBe(10);
    expect(r.predicted_piece).toEqual({ name: 'research', reason: 'Web 調査だから' });
    expect(r.maestro_tips).toHaveLength(1);
    expect(r.personalized).toEqual(['既にスキル所有']);
  });

  it('候補に無い predicted_piece は null に落とす', () => {
    const r = normalizeCoachResult(
      { overall: 50, rewrite: 'x', predicted_piece: { name: 'does-not-exist', reason: 'r' } },
      candidates,
    );
    expect(r.predicted_piece).toBeNull();
  });

  it('壊れた/欠損フィールドでも安全な既定値を返す', () => {
    const r = normalizeCoachResult({}, candidates);
    expect(r.overall).toBe(0);
    expect(r.axes).toEqual([]);
    expect(r.rewrite).toBe('');
    expect(r.predicted_piece).toBeNull();
    expect(r.maestro_tips).toEqual([]);
    expect(r.personalized).toEqual([]);
  });
});

describe('runPromptCoach', () => {
  it('callLlm の生出力を正規化して返す', async () => {
    const deps = makeDeps({
      callLlm: vi.fn().mockResolvedValue({
        overall: 80,
        axes: [{ name: '具体性', score: 7, comment: '良い' }],
        rewrite: 'もっと具体的に',
        predicted_piece: { name: 'chat', reason: '汎用' },
        maestro_tips: [],
        personalized: [],
      }),
    });
    const r = await runPromptCoach(deps, REQ);
    expect(r.overall).toBe(80);
    expect(r.predicted_piece?.name).toBe('chat');
    expect(deps.callLlm).toHaveBeenCalledTimes(1);
  });

  it('LLM エラーは伝播する', async () => {
    const deps = makeDeps({
      callLlm: vi.fn().mockRejectedValue(new Error('LLM down')),
    });
    await expect(runPromptCoach(deps, REQ)).rejects.toThrow('LLM down');
  });
});
