import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import type { MovementResult } from './agent-loop.js';
import type { PieceDef } from './piece-runner.js';

// House style §4 "piece-runner tests — mock agent-loop instead": mock
// ./agent-loop.js and script MovementResults; build pieces with a local
// makePiece() factory. We exercise the higher-level runPiece contract.
vi.mock('./agent-loop.js', () => ({
  executeMovement: vi.fn(),
}));

import { executeMovement } from './agent-loop.js';
import { runPiece, loadAllPieceTriggers } from './piece-runner.js';

const executeMovementMock = vi.mocked(executeMovement);

function makeWorkspace(): string {
  return mkdtempSync(join(tmpdir(), 'maestro-piece-sentinels-'));
}

// A two-movement piece: `execute` transitions to `verify`, `verify` completes.
// `rules` are empty and we use `default_next`; the runner trusts whatever
// `next` the (mocked) movement returns when it is a real movement name.
function makeTwoMovementPiece(): PieceDef {
  return {
    name: 'sentinel-piece',
    description: 'test',
    max_movements: 10,
    initial_movement: 'execute',
    movements: [
      { name: 'execute', edit: true, persona: 'worker', instruction: 'execute', allowed_tools: [], rules: [], default_next: 'verify' },
      { name: 'verify', edit: false, persona: 'reviewer', instruction: 'verify', allowed_tools: [], rules: [], default_next: 'COMPLETE' },
    ],
  };
}

// A single-movement piece used for the pause-sentinel tests.
function makeSingleMovementPiece(): PieceDef {
  return {
    name: 'sentinel-piece',
    description: 'test',
    max_movements: 10,
    initial_movement: 'execute',
    movements: [
      { name: 'execute', edit: true, persona: 'worker', instruction: 'execute', allowed_tools: [], rules: [], default_next: 'COMPLETE' },
    ],
  };
}

describe('piece-runner WAITING_HUMAN sentinels (ENG-037)', () => {
  let workspacePath = '';
  beforeEach(() => { workspacePath = makeWorkspace(); });
  afterEach(() => {
    executeMovementMock.mockReset();
    if (workspacePath) { rmSync(workspacePath, { recursive: true, force: true }); workspacePath = ''; }
  });

  it('pauses as waiting_human (not COMPLETE) on WAITING_HUMAN_BROWSER', async () => {
    const result: MovementResult = {
      next: 'WAITING_HUMAN_BROWSER',
      output: 'ログインが必要です',
      toolsUsed: ['BrowseWeb'],
      browserSessionId: 'sess-abc',
      waitReason: 'browser_login',
    };
    executeMovementMock.mockResolvedValueOnce(result);

    const run = await runPiece(makeSingleMovementPiece(), 'TASK', {} as never, workspacePath);

    // Must be a true pause — NOT a false COMPLETE and NOT a hang.
    expect(run.status).toBe('waiting_human');
    expect(run.waitReason).toBe('browser_login');
    expect(run.browserSessionId).toBe('sess-abc');
    // The same movement is resumed once the human acts.
    expect(run.resumeMovement).toBe('execute');
    expect(run.finalOutput).toBe('ログインが必要です');
    expect(run.abortReason).toBeNull();
  });

  it('defaults waitReason to browser_login when the movement omits it', async () => {
    executeMovementMock.mockResolvedValueOnce({
      next: 'WAITING_HUMAN_BROWSER',
      output: 'wait',
      toolsUsed: [],
      // no waitReason, no browserSessionId
    });
    const run = await runPiece(makeSingleMovementPiece(), 'TASK', {} as never, workspacePath);
    expect(run.status).toBe('waiting_human');
    expect(run.waitReason).toBe('browser_login');
    expect(run.browserSessionId).toBeNull();
  });

  it('pauses as waiting_human (not COMPLETE) on WAITING_HUMAN_TOOL_REQUEST', async () => {
    executeMovementMock.mockResolvedValueOnce({
      next: 'WAITING_HUMAN_TOOL_REQUEST',
      output: 'ツールの承認が必要です',
      toolsUsed: ['RequestTool'],
    });
    const run = await runPiece(makeSingleMovementPiece(), 'TASK', {} as never, workspacePath);

    expect(run.status).toBe('waiting_human');
    expect(run.waitReason).toBe('tool_request');
    expect(run.resumeMovement).toBe('execute');
    expect(run.finalOutput).toBe('ツールの承認が必要です');
    expect(run.abortReason).toBeNull();
  });

  it('does not advance past a WAITING_HUMAN pause (executeMovement called exactly once)', async () => {
    executeMovementMock.mockResolvedValue({
      next: 'WAITING_HUMAN_TOOL_REQUEST',
      output: 'pause',
      toolsUsed: [],
    });
    await runPiece(makeTwoMovementPiece(), 'TASK', {} as never, workspacePath);
    // If the pause leaked into a false COMPLETE/continue, the second movement
    // would run. The pause must stop the loop after the first movement.
    expect(executeMovementMock).toHaveBeenCalledTimes(1);
  });
});

describe('loadAllPieceTriggers (ENG-053)', () => {
  let baseDir = '';
  let builtinDir = '';
  let customDir = '';

  beforeEach(() => {
    baseDir = mkdtempSync(join(tmpdir(), 'maestro-triggers-'));
    builtinDir = join(baseDir, 'pieces');
    customDir = join(baseDir, 'custom');
    mkdirSync(builtinDir, { recursive: true });
    mkdirSync(customDir, { recursive: true });
  });
  afterEach(() => {
    if (baseDir) { rmSync(baseDir, { recursive: true, force: true }); baseDir = ''; }
  });

  // Minimal valid piece YAML with a triggers.keywords block.
  function writePiece(dir: string, name: string, keywords: string[]): void {
    const kw = keywords.map(k => `    - "${k}"`).join('\n');
    // NOTE: terminal `COMPLETE` is only legal as `default_next` (engine-internal
    // sentinel), never in rules[].next — loadPiece rejects the latter.
    const yaml = `name: ${name}
description: ${name} desc
initial_movement: execute
movements:
  - name: execute
    persona: worker
    edit: true
    instruction: do it
    rules: []
    default_next: COMPLETE
triggers:
  keywords:
${kw}
`;
    writeFileSync(join(dir, `${name}.yaml`), yaml, 'utf-8');
  }

  it('loads keywords from a piece', () => {
    writePiece(builtinDir, 'research', ['調査', 'レポート']);
    const triggers = loadAllPieceTriggers(builtinDir);
    const research = triggers.find(t => t.name === 'research');
    expect(research).toBeDefined();
    expect(research!.keywords).toEqual(['調査', 'レポート']);
  });

  it('lets a custom-dir piece override a built-in piece of the same name (custom wins)', () => {
    writePiece(builtinDir, 'research', ['builtin-kw']);
    writePiece(customDir, 'research', ['custom-kw']);
    const triggers = loadAllPieceTriggers(builtinDir, customDir);
    const research = triggers.filter(t => t.name === 'research');
    // De-duplicated by name; only the custom (first-scanned) entry survives.
    expect(research).toHaveLength(1);
    expect(research[0].keywords).toEqual(['custom-kw']);
  });

  it('skips an invalid piece file instead of throwing (not fatal)', () => {
    writePiece(builtinDir, 'good', ['ok']);
    // Invalid: no movements / not valid per validatePieceDef → loadPiece throws,
    // and loadAllPieceTriggers must swallow it and keep the good one.
    writeFileSync(join(builtinDir, 'broken.yaml'), 'name: broken\nnot: valid yaml structure :::\n', 'utf-8');
    let triggers: Array<{ name: string; keywords: string[] }> = [];
    expect(() => { triggers = loadAllPieceTriggers(builtinDir); }).not.toThrow();
    expect(triggers.some(t => t.name === 'good')).toBe(true);
    expect(triggers.some(t => t.name === 'broken')).toBe(false);
  });

  it('tolerates a missing directory without throwing', () => {
    const missing = join(baseDir, 'does-not-exist');
    expect(() => loadAllPieceTriggers(missing)).not.toThrow();
    expect(loadAllPieceTriggers(missing)).toEqual([]);
  });

  it('omits pieces that declare no trigger keywords', () => {
    // A piece with no triggers block should not appear in the trigger list.
    const yaml = `name: nokw
description: no keywords
initial_movement: execute
movements:
  - name: execute
    persona: worker
    edit: true
    instruction: do it
    rules: []
    default_next: COMPLETE
`;
    writeFileSync(join(builtinDir, 'nokw.yaml'), yaml, 'utf-8');
    const triggers = loadAllPieceTriggers(builtinDir);
    expect(triggers.some(t => t.name === 'nokw')).toBe(false);
  });
});

describe('lessons injection + cap + writeLessonLog (ENG-039)', () => {
  let workspacePath = '';
  beforeEach(() => { workspacePath = makeWorkspace(); });
  afterEach(() => {
    executeMovementMock.mockReset();
    if (workspacePath) { rmSync(workspacePath, { recursive: true, force: true }); workspacePath = ''; }
  });

  it('injects movement-1 lessons into the movement-2 instruction', async () => {
    const instructions: string[] = [];
    const results: MovementResult[] = [
      { next: 'verify', output: 'm1 done', toolsUsed: [], lessons: 'ファイルは output/ に置くこと' },
      { next: 'COMPLETE', output: 'all done', toolsUsed: [] },
    ];
    executeMovementMock.mockImplementation(async (_movement, instruction) => {
      instructions.push(instruction);
      const next = results.shift();
      if (!next) throw new Error('no mock result left');
      return next;
    });

    const run = await runPiece(makeTwoMovementPiece(), 'TASK', {} as never, workspacePath);
    expect(run.status).toBe('completed');
    // Movement 1 sees the raw task.
    expect(instructions[0]).toBe('TASK');
    // Movement 2 carries the accumulated lesson.
    expect(instructions[1]).toContain('前のステップで得た教訓');
    expect(instructions[1]).toContain('ファイルは output/ に置くこと');
    expect(instructions[1]).toContain('[execute]');
  });

  it('truncates a >2000-char lessons block at the cap boundary when injected', async () => {
    const huge = 'X'.repeat(5000);
    const instructions: string[] = [];
    const results: MovementResult[] = [
      { next: 'verify', output: 'm1', toolsUsed: [], lessons: huge },
      { next: 'COMPLETE', output: 'done', toolsUsed: [] },
    ];
    executeMovementMock.mockImplementation(async (_movement, instruction) => {
      instructions.push(instruction);
      const next = results.shift();
      if (!next) throw new Error('no mock result left');
      return next;
    });

    await runPiece(makeTwoMovementPiece(), 'TASK', {} as never, workspacePath);

    const m2 = instructions[1];
    // The lessons block (header + entries) is capped at MAX_LESSONS_LENGTH=2000,
    // then '...' is appended. Extract the lessons block to assert the boundary.
    const marker = '前のステップで得た教訓';
    const blockStart = m2.indexOf('---\n## ' + marker);
    expect(blockStart).toBeGreaterThanOrEqual(0);
    const block = m2.slice(blockStart);
    // 2000-char hard cap + '...' suffix (single entry can't be dropped further).
    expect(block.endsWith('...')).toBe(true);
    expect(block.length).toBe(2000 + 3);
    // The huge run of X's is present but truncated — not the full 5000 chars.
    expect(block).toContain('XXXX');
    expect(block.length).toBeLessThan(huge.length);
  });

  it('writes each lesson to logs/lessons.jsonl via writeLessonLog', async () => {
    const results: MovementResult[] = [
      { next: 'verify', output: 'm1', toolsUsed: [], lessons: 'lesson-one' },
      { next: 'COMPLETE', output: 'final summary text', toolsUsed: [] },
    ];
    executeMovementMock.mockImplementation(async () => {
      const next = results.shift();
      if (!next) throw new Error('no mock result left');
      return next;
    });

    await runPiece(makeTwoMovementPiece(), 'TASK', {} as never, workspacePath);

    const logPath = join(workspacePath, 'logs', 'lessons.jsonl');
    expect(existsSync(logPath)).toBe(true);
    const lines = readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean);
    const entries = lines.map(l => JSON.parse(l));
    // Movement 1 explicit lesson + movement 2 COMPLETE-output fallback lesson.
    expect(entries).toHaveLength(2);
    expect(entries[0]).toMatchObject({ piece: 'sentinel-piece', movement: 'execute', lessons: 'lesson-one' });
    expect(entries[1]).toMatchObject({ movement: 'verify', lessons: 'final summary text' });
    expect(typeof entries[0].timestamp).toBe('string');
  });
});
