/**
 * Integration test: runPiece owns a Conversation and persists transcript.jsonl
 * across movements (Phase A).
 *
 * Approach: does NOT mock agent-loop.js — exercises the real executeMovement.
 * Mocks tools/index.js (same pattern as agent-loop.test.ts) and uses a
 * FakeClient that emits scripted tool_use events.
 */

import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { LLMEvent, ToolDef } from '../llm/openai-compat.js';
import type { PieceDef } from './piece-runner.js';

// --- Mock tools/index.js before any import of agent-loop / piece-runner ---
const { executeToolMock, getToolDefsMock } = vi.hoisted(() => ({
  executeToolMock: vi.fn(),
  getToolDefsMock: vi.fn(),
}));

vi.mock('./tools/index.js', () => ({
  executeTool: executeToolMock,
  getToolDefs: getToolDefsMock,
}));

// Import after mocking
import { runPiece } from './piece-runner.js';
import { Conversation } from './context/conversation.js';

// ---------------------------------------------------------------------------
// FakeClient — mirrors the one in agent-loop.test.ts
// ---------------------------------------------------------------------------
class FakeClient {
  private index = 0;
  readonly calls: Array<{ messages: unknown; tools?: unknown }> = [];

  constructor(private readonly responses: LLMEvent[][]) {}

  async *chat(messages: unknown, tools?: unknown, _signal?: AbortSignal): AsyncGenerator<LLMEvent> {
    this.calls.push({ messages, tools });
    const response = this.responses[this.index++] ?? [];
    for (const event of response) {
      yield event;
    }
  }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeToolDefs(names: string[]): ToolDef[] {
  return names.map((name) => ({
    type: 'function' as const,
    function: {
      name,
      description: name,
      parameters: { type: 'object', properties: {}, required: [] },
    },
  }));
}

/**
 * Creates a tmp workspace and a separate logsRoot dir inside it.
 * Returns both paths so the caller can pass logsRoot in runPiece options.
 */
function makeTmpWorkspace(): { workspacePath: string; logsRoot: string } {
  const workspacePath = mkdtempSync(join(tmpdir(), 'piece-runner-conv-test-'));
  const logsRoot = join(workspacePath, 'logs');
  mkdirSync(logsRoot, { recursive: true });
  return { workspacePath, logsRoot };
}

/**
 * A 2-movement piece: m1 → (transition) → m2 → (complete).
 */
function twoMovementPiece(): PieceDef {
  return {
    name: 'conv-test-piece',
    description: 'integration test piece',
    max_movements: 10,
    initial_movement: 'm1',
    movements: [
      {
        name: 'm1',
        edit: false,
        persona: 'worker',
        instruction: 'First movement.',
        allowed_tools: [],
        rules: [{ condition: 'done', next: 'm2' }],
        default_next: 'm2',
      },
      {
        name: 'm2',
        edit: false,
        persona: 'worker',
        instruction: 'Second movement.',
        allowed_tools: [],
        rules: [],
        default_next: undefined,
      },
    ],
  };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

// Unique sentinel that must NOT appear in job-2's first LLM call messages
// when handoff carry-over is correctly suppressed by replayed prior turns.
const LIMIT1_SENTINEL = 'LIMIT1_SENTINEL_af7c9e2b_UNIQUE';

// ---------------------------------------------------------------------------
// SpyFakeClient — like FakeClient but SNAPSHOTS messages at call time so
// inspection after runPiece completes is not affected by later mutations
// to the shared conversation.messages array.
// ---------------------------------------------------------------------------
class SpyFakeClient {
  private index = 0;
  /** Snapshots of messages arrays, taken at the moment of each LLM call. */
  readonly snapshots: Array<Array<{ role: string; content?: unknown; tool_calls?: Array<{ id: string; function: { name: string } }>; tool_call_id?: string }>> = [];

  constructor(private readonly responses: LLMEvent[][]) {}

  async *chat(messages: unknown, tools?: unknown, _signal?: AbortSignal): AsyncGenerator<LLMEvent> {
    // Snapshot the array at this moment (deep copy of the array, shallow copy of items)
    this.snapshots.push((messages as Array<Record<string, unknown>>).map((m) => ({ ...m })) as never);
    const response = this.responses[this.index++] ?? [];
    for (const event of response) {
      yield event;
    }
  }
}

describe('runPiece conversation continuity (Phase A)', () => {
  let workspacePath = '';

  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
    if (workspacePath) {
      rmSync(workspacePath, { recursive: true, force: true });
      workspacePath = '';
    }
  });

  it('movement 2 sees movement 1 history and transcript.jsonl is written', async () => {
    const tmp = makeTmpWorkspace();
    workspacePath = tmp.workspacePath;
    const { logsRoot } = tmp;

    // getToolDefs returns an empty list — movements have no allowed_tools
    getToolDefsMock.mockResolvedValue(makeToolDefs([]));
    // executeTool not expected to be called (no tool_use from LLM)
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    // Scripted client:
    //   Call 0 (m1): LLM emits transition({next_step:'m2'})
    //   Call 1 (m2): LLM emits complete({status:'success',result:'all done'})
    const client = new FakeClient([
      [
        {
          type: 'tool_use',
          id: 'tr1',
          name: 'transition',
          input: { next_step: 'm2', summary: 'moving to m2' },
        },
        { type: 'done', usage: { prompt_tokens: 100, completion_tokens: 5 } },
      ],
      [
        {
          type: 'tool_use',
          id: 'cp1',
          name: 'complete',
          input: { status: 'success', result: 'all done' },
        },
        { type: 'done', usage: { prompt_tokens: 100, completion_tokens: 5 } },
      ],
    ]);

    const piece = twoMovementPiece();
    const result = await runPiece(
      piece,
      'INTEGRATION TASK',
      client as never,
      workspacePath,
      undefined,
      undefined,
      { runtimeDir: logsRoot },
    );

    expect(result.status).toBe('completed');

    // --- transcript.jsonl must exist ---
    const transcript = join(logsRoot, 'transcript.jsonl');
    expect(existsSync(transcript)).toBe(true);

    // --- loadFrom returns the messages ---
    const loaded = Conversation.loadFrom(transcript);
    expect(loaded.length).toBeGreaterThan(0);

    // taskInstruction appears exactly once (no per-movement reset)
    const taskMsgs = loaded.filter(
      (m) => typeof m.content === 'string' && m.content === 'INTEGRATION TASK',
    );
    expect(taskMsgs).toHaveLength(1);

    // movement-2 guidance must appear (証明 enterMovement fired for m2).
    // 役割は `user`: 厳格テンプレート対応で system は先頭1つに統一されたため、
    // 途中注入の movement guidance は user メッセージとして積まれる
    // (conversation.enterMovement / agent-loop.test.ts と同じ前提)。
    const m2Guidance = loaded.some(
      (m) => m.role === 'user' && typeof m.content === 'string' && m.content.includes('現在のステップ: m2'),
    );
    expect(m2Guidance).toBe(true);
  });

  it('P2: continuation job replays prior transcript turns, suppresses LIMIT-1 handoff', async () => {
    const tmp = makeTmpWorkspace();
    workspacePath = tmp.workspacePath;
    const { logsRoot } = tmp;

    // Include 'Read' so getToolDefsMock returns it — agent-loop only exposes listed tools
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    // executeToolMock handles any tool call; returns a plausible Read result
    executeToolMock.mockResolvedValue({ output: 'file contents', isError: false });

    // --- Job 1: single-movement piece, completes after one real tool call ---
    const piece = twoMovementPiece();

    const clientJob1 = new FakeClient([
      // m1, call 0: LLM emits a real (non-control-flow) Read tool call
      // This produces an [assistant tool_call + tool result] pair in the transcript.
      // replayableTurns must KEEP this pair (it is not a control-flow call).
      [
        {
          type: 'tool_use',
          id: 'j1_read1',
          name: 'Read',
          input: { file_path: '/workspace/file.txt' },
        },
        { type: 'done', usage: { prompt_tokens: 100, completion_tokens: 5 } },
      ],
      // m1, call 1: after seeing the Read result, LLM transitions to m2
      [
        {
          type: 'tool_use',
          id: 'j1_tr1',
          name: 'transition',
          input: { next_step: 'm2', summary: 'moving to m2' },
        },
        { type: 'done', usage: { prompt_tokens: 100, completion_tokens: 5 } },
      ],
      // m2: complete
      [
        {
          type: 'tool_use',
          id: 'j1_cp1',
          name: 'complete',
          input: { status: 'success', result: 'job1 final result' },
        },
        { type: 'done', usage: { prompt_tokens: 100, completion_tokens: 5 } },
      ],
    ]);

    const result1 = await runPiece(
      piece,
      'ORIGINAL_TASK_INSTRUCTION_J1',
      clientJob1 as never,
      workspacePath,
      undefined,
      undefined,
      { runtimeDir: logsRoot },
    );
    expect(result1.status).toBe('completed');

    // transcript.jsonl must exist after job 1
    const transcriptPath = join(logsRoot, 'transcript.jsonl');
    expect(existsSync(transcriptPath)).toBe(true);

    // --- Job 2: continuation with handoffContext ---
    // Use SpyFakeClient which SNAPSHOTS messages at call-time, so reading
    // snapshots[0] after runPiece completes gives the state at the first
    // LLM call (not the grown array after subsequent movements ran).
    const clientJob2 = new SpyFakeClient([
      // m1: transition → m2
      [
        {
          type: 'tool_use',
          id: 'j2_tr1',
          name: 'transition',
          input: { next_step: 'm2', summary: 'continuing' },
        },
        { type: 'done', usage: { prompt_tokens: 100, completion_tokens: 5 } },
      ],
      // m2: complete
      [
        {
          type: 'tool_use',
          id: 'j2_cp1',
          name: 'complete',
          input: { status: 'success', result: 'job2 final result' },
        },
        { type: 'done', usage: { prompt_tokens: 100, completion_tokens: 5 } },
      ],
    ]);

    const result2 = await runPiece(
      piece,
      'FOLLOW_UP_TASK_INSTRUCTION_J2',
      clientJob2 as never,
      workspacePath,
      undefined,
      undefined,
      {
        runtimeDir: logsRoot,
        handoffContext: {
          prevPiece: piece.name,
          prevResult: LIMIT1_SENTINEL,
        },
      },
    );
    expect(result2.status).toBe('completed');

    // Extract messages job2's client received on its FIRST LLM call.
    // snapshots[0] is a shallow-item snapshot taken at the moment of the first call.
    expect(clientJob2.snapshots.length).toBeGreaterThan(0);
    const job2FirstMessages = clientJob2.snapshots[0];

    const allContent = job2FirstMessages
      .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '')))
      .join('\n');

    // (a) job-1's original task instruction is present in the replayed thread
    expect(allContent).toContain('ORIGINAL_TASK_INSTRUCTION_J1');

    // (b) LIMIT-1 handoff sentinel is NOT in the sent messages
    // (replayed prior turns supersede the handoffContext block)
    expect(allContent).not.toContain(LIMIT1_SENTINEL);

    // (c) the LAST message is a user message containing job-2's instruction
    const lastMsg = job2FirstMessages[job2FirstMessages.length - 1];
    expect(lastMsg.role).toBe('user');
    expect(typeof lastMsg.content === 'string' && lastMsg.content.includes('FOLLOW_UP_TASK_INSTRUCTION_J2')).toBe(true);

    // (d) provider validity: every assistant tool_call id has a matching tool result.
    // job-1 emitted a real 'Read' tool call (j1_read1) before transitioning — that
    // pair must SURVIVE replayableTurns (it is not a control-flow call), so
    // assistantCallIds is non-empty and the pairing loop is provably non-vacuous.
    // Control-flow calls (complete/transition) from job-1 must have been stripped
    // so there are no dangling ids without a matching result.
    const assistantCallIds = job2FirstMessages.flatMap(
      (m) => (m.role === 'assistant' && m.tool_calls ? m.tool_calls.map((tc) => tc.id) : []),
    );
    const toolResultIds = new Set(
      job2FirstMessages
        .filter((m) => m.role === 'tool' && m.tool_call_id)
        .map((m) => m.tool_call_id as string),
    );
    // Non-vacuousness guard: the replayed turns must include at least one real tool call
    expect(assistantCallIds.length).toBeGreaterThan(0);
    // Pairing invariant: every assistant tool_call id must have a matching tool result
    for (const id of assistantCallIds) {
      expect(toolResultIds.has(id)).toBe(true);
    }

    // (e) transcript was rewritten to the job-2 thread — job-1's preamble system
    // messages (those containing 'ORIGINAL_TASK_INSTRUCTION_J1') exist in the
    // replayed turns but there should only be the job-2 system seed messages
    // as 'system' messages now (job-1 system entries were stripped by replayableTurns).
    const finalTranscript = Conversation.loadFrom(transcriptPath);
    // The job-2 'seed' wrote two system messages (preamble + guidance) at the top.
    // Job-1's system messages (which are stripped by replayableTurns before being
    // inserted as priorTurns) must NOT reappear as system role entries.
    // We verify by checking that the only system messages are the job-2 seed ones:
    // they will contain 'FOLLOW_UP_TASK_INSTRUCTION_J2' in the user message following them,
    // and ORIGINAL_TASK_INSTRUCTION_J1 must appear exactly once (as a user-role turn, replayed).
    const j1TurnCount = finalTranscript.filter(
      (m) => typeof m.content === 'string' && m.content === 'ORIGINAL_TASK_INSTRUCTION_J1',
    ).length;
    expect(j1TurnCount).toBe(1);

    // The j1 instruction should appear as a user message (replayed turn), not system
    const j1AsSystem = finalTranscript.some(
      (m) => m.role === 'system' && typeof m.content === 'string' && m.content === 'ORIGINAL_TASK_INSTRUCTION_J1',
    );
    expect(j1AsSystem).toBe(false);
  });
});
