import { describe, it, expect } from 'vitest';
import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Conversation } from './conversation.js';
import type { Message } from '../../llm/openai-compat.js';

describe('Conversation in-memory', () => {
  it('seed merges preamble + guidance into one leading system message', () => {
    const c = new Conversation();
    c.seed('PREAMBLE', 'GUIDANCE-1', 'do the thing');
    // Exactly one system message, at index 0, so strict chat templates that
    // forbid a system message after the first index do not reject the request.
    expect(c.messages.map((m) => m.role)).toEqual(['system', 'user']);
    expect(c.messages[0]!.content).toBe('PREAMBLE\n\nGUIDANCE-1');
    expect(c.messages[1]!.content).toBe('do the thing');
  });

  it('enterMovement appends guidance as a user message without resetting prior history', () => {
    const c = new Conversation();
    c.seed('PREAMBLE', 'GUIDANCE-1', 'task');
    c.messages.push({ role: 'assistant', content: 'm1 work' });
    c.enterMovement('GUIDANCE-2');
    expect(c.messages.map((m) => m.content)).toContain('m1 work');
    // Mid-conversation guidance must be `user`, never `system`.
    expect(c.messages[c.messages.length - 1]!).toEqual({ role: 'user', content: 'GUIDANCE-2' });
    expect(c.messages.filter((m) => m.role === 'system')).toHaveLength(1);
  });
});

describe('Conversation persistence', () => {
  it('flush appends only new messages and loadFrom round-trips', () => {
    const dir = mkdtempSync(join(tmpdir(), 'conv-'));
    const path = join(dir, 'transcript.jsonl');
    const c = new Conversation(path);
    c.seed('P', 'G1', 'task');           // 2 行（system 統合 + user）
    c.messages.push({ role: 'assistant', content: 'a1' });
    c.flush();                            // +1 行
    expect(readFileSync(path, 'utf8').trim().split('\n')).toHaveLength(3);

    const loaded = Conversation.loadFrom(path);
    expect(loaded).toEqual([
      { role: 'system', content: 'P\n\nG1' },
      { role: 'user', content: 'task' },
      { role: 'assistant', content: 'a1' },
    ]);
  });

  it('rewrite truncates and reflects in-place compaction', () => {
    const dir = mkdtempSync(join(tmpdir(), 'conv-'));
    const path = join(dir, 'transcript.jsonl');
    const c = new Conversation(path);
    c.seed('P', 'G1', 'task');
    c.messages.push({ role: 'assistant', content: 'big-1' });
    c.messages.push({ role: 'assistant', content: 'big-2' });
    c.flush();
    // simulate summarizeHistory in-place compaction (seed is now 2 messages, so
    // the two big assistant turns sit at indices 2 and 3).
    c.messages.splice(2, 2, { role: 'assistant', content: 'SUMMARY' });
    c.rewrite();
    const loaded = Conversation.loadFrom(path);
    expect(loaded).toHaveLength(3);
    expect(loaded[2]).toEqual({ role: 'assistant', content: 'SUMMARY' });
  });

  it('no transcriptPath is a no-op (never throws)', () => {
    const c = new Conversation();
    expect(() => { c.seed('P', 'G', 't'); c.flush(); c.rewrite(); }).not.toThrow();
  });

  it('re-seed resets persistedCount and replaces messages', () => {
    const dir = mkdtempSync(join(tmpdir(), 'conv-'));
    const path = join(dir, 'transcript.jsonl');
    const c = new Conversation(path);

    // First seed + flush
    c.seed('P1', 'G1', 'task1');
    c.messages.push({ role: 'assistant', content: 'result1' });
    c.messages.push({ role: 'assistant', content: 'result2' });
    c.flush();

    const fileLinesBefore = readFileSync(path, 'utf8').trim().split('\n').length;
    expect(fileLinesBefore).toBe(4); // 2 seed + 2 results

    // Re-seed: should reset messages and persistedCount
    c.seed('P2', 'G2', 'task2');
    expect(c.messages).toHaveLength(2);
    c.messages.push({ role: 'assistant', content: 'result3' });
    c.flush();

    // In-memory state should reflect the new seed only (2 seed + 1 result3)
    expect(c.messages).toHaveLength(3);

    // Load from file and verify: after re-seed, only the new seed+result3 persisted
    // (The old lines are still in file because flush() appends, but seed() + flush() + load should show only current in-memory state)
    const loaded = Conversation.loadFrom(path);
    void loaded;
    expect(c.messages).toHaveLength(3);
    expect(c.messages[0]).toEqual({ role: 'system', content: 'P2\n\nG2' });
    expect(c.messages[1]).toEqual({ role: 'user', content: 'task2' });
    expect(c.messages[2]).toEqual({ role: 'assistant', content: 'result3' });
  });

  it('loadFrom skips malformed/truncated JSON lines', () => {
    const dir = mkdtempSync(join(tmpdir(), 'conv-'));
    const path = join(dir, 'transcript.jsonl');

    // Write a file with valid + invalid + valid lines
    const validLine1 = JSON.stringify({ role: 'system', content: 'P', _meta: { ts: 1000 } });
    const invalidLine = 'this is not JSON { broken';
    const validLine2 = JSON.stringify({ role: 'user', content: 'task', _meta: { ts: 2000 } });

    const fs = require('node:fs');
    fs.writeFileSync(path, `${validLine1}\n${invalidLine}\n${validLine2}\n`);

    const loaded = Conversation.loadFrom(path);
    expect(loaded).toHaveLength(2);
    expect(loaded[0]).toEqual({ role: 'system', content: 'P' });
    expect(loaded[1]).toEqual({ role: 'user', content: 'task' });
  });

  it('I/O errors (directory path, permissions) log warn but never throw', () => {
    const dir = mkdtempSync(join(tmpdir(), 'conv-'));
    // Pass a directory path instead of a file path
    const dirPath = join(dir, 'subdir');
    const fs = require('node:fs');
    fs.mkdirSync(dirPath);

    const c = new Conversation(dirPath);
    c.seed('P', 'G', 'task');

    // None of these should throw, even though the path is a directory
    expect(() => c.flush()).not.toThrow();
    expect(() => c.rewrite()).not.toThrow();
    expect(() => c.seed('P2', 'G2', 'task2')).not.toThrow();
  });
});

describe('Conversation.replayableTurns', () => {
  it('strips system messages, keeps user/assistant/tool turns', () => {
    const msgs: Message[] = [
      { role: 'system', content: 'old preamble' },
      { role: 'system', content: 'old guidance' },
      { role: 'user', content: 'do X' },
      { role: 'assistant', content: 'working on it' },
    ];
    const out = Conversation.replayableTurns(msgs);
    expect(out).toEqual([
      { role: 'user', content: 'do X' },
      { role: 'assistant', content: 'working on it' },
    ]);
  });

  it('drops control-flow tool_calls (complete/transition) and the now-empty assistant', () => {
    const msgs: Message[] = [
      { role: 'user', content: 'go' },
      { role: 'assistant', content: '', tool_calls: [
        { id: 'c1', type: 'function', function: { name: 'complete', arguments: '{}' } },
      ] },
    ];
    const out = Conversation.replayableTurns(msgs);
    expect(out).toEqual([{ role: 'user', content: 'go' }]);
  });

  it('drops a non-control tool_call that has no matching tool result (dangling)', () => {
    const msgs: Message[] = [
      { role: 'user', content: 'go' },
      { role: 'assistant', content: 'reading', tool_calls: [
        { id: 'r1', type: 'function', function: { name: 'Read', arguments: '{}' } },
      ] },
      // no tool result for r1 (job crashed before result)
    ];
    const out = Conversation.replayableTurns(msgs);
    // tool_call stripped, but content remains so assistant stays
    expect(out).toEqual([
      { role: 'user', content: 'go' },
      { role: 'assistant', content: 'reading' },
    ]);
  });

  it('keeps a valid assistant tool_call + matching tool result', () => {
    const msgs: Message[] = [
      { role: 'assistant', content: '', tool_calls: [
        { id: 'r1', type: 'function', function: { name: 'Read', arguments: '{}' } },
      ] },
      { role: 'tool', tool_call_id: 'r1', content: 'file body' },
    ];
    const out = Conversation.replayableTurns(msgs);
    expect(out).toEqual(msgs);
  });

  it('drops an orphan tool message with no matching assistant tool_call', () => {
    const msgs: Message[] = [
      { role: 'user', content: 'go' },
      { role: 'tool', tool_call_id: 'ghost', content: 'orphan' },
    ];
    const out = Conversation.replayableTurns(msgs);
    expect(out).toEqual([{ role: 'user', content: 'go' }]);
  });

  it('partial-strips a mixed assistant: keeps the real tool_call + result, drops the control-flow one', () => {
    const msgs: Message[] = [
      { role: 'user', content: 'go' },
      { role: 'assistant', content: 'doing both', tool_calls: [
        { id: 'r1', type: 'function', function: { name: 'Read', arguments: '{}' } },
        { id: 'c1', type: 'function', function: { name: 'complete', arguments: '{}' } },
      ] },
      { role: 'tool', tool_call_id: 'r1', content: 'file body' },
    ];
    const out = Conversation.replayableTurns(msgs);
    expect(out).toEqual([
      { role: 'user', content: 'go' },
      { role: 'assistant', content: 'doing both', tool_calls: [
        { id: 'r1', type: 'function', function: { name: 'Read', arguments: '{}' } },
      ] },
      { role: 'tool', tool_call_id: 'r1', content: 'file body' },
    ]);
  });

  it('drops a control-flow assistant AND its dangling tool result (no orphan survives)', () => {
    // The complete tool_call is stripped in Pass 1; its tool result id never enters
    // keptCallIds, so Pass 3 removes the result as an orphan. This is the subtlest
    // invariant: a control-flow result must not leak into the replayed thread.
    const msgs: Message[] = [
      { role: 'user', content: 'go' },
      { role: 'assistant', content: '', tool_calls: [
        { id: 'c1', type: 'function', function: { name: 'complete', arguments: '{"status":"success"}' } },
      ] },
      { role: 'tool', tool_call_id: 'c1', content: 'completed' },
    ];
    const out = Conversation.replayableTurns(msgs);
    expect(out).toEqual([{ role: 'user', content: 'go' }]);
  });
});

describe('Conversation.seedContinuation', () => {
  it('builds [merged-system, ...priorTurns, user] and rewrites the transcript', () => {
    const dir = mkdtempSync(join(tmpdir(), 'p2-'));
    const path = join(dir, 'transcript.jsonl');
    // Pre-existing transcript with an old job's content.
    writeFileSync(path, JSON.stringify({ role: 'user', content: 'old' }) + '\n');
    const conv = new Conversation(path);
    const prior: Message[] = [
      { role: 'user', content: 'first task' },
      { role: 'assistant', content: 'done first' },
    ];
    conv.seedContinuation('PRE', 'GUIDE', prior, 'new instruction');
    expect(conv.messages).toEqual([
      { role: 'system', content: 'PRE\n\nGUIDE' },
      { role: 'user', content: 'first task' },
      { role: 'assistant', content: 'done first' },
      { role: 'user', content: 'new instruction' },
    ]);
    // File rewritten (truncated): exactly 4 lines, no leftover 'old'.
    const lines = readFileSync(path, 'utf8').trim().split('\n');
    expect(lines).toHaveLength(4);
    expect(readFileSync(path, 'utf8')).not.toContain('"content":"old"');
  });
});

describe('Conversation.hasReplayableTranscript', () => {
  it('returns false when path is undefined', () => {
    expect(Conversation.hasReplayableTranscript(undefined)).toBe(false);
  });

  it('returns false when the file does not exist', () => {
    const dir = mkdtempSync(join(tmpdir(), 'p3-'));
    expect(Conversation.hasReplayableTranscript(join(dir, 'nope.jsonl'))).toBe(false);
  });

  it('returns false when the transcript has only system messages (zero replayable turns)', () => {
    const dir = mkdtempSync(join(tmpdir(), 'p3-'));
    const p = join(dir, 't.jsonl');
    writeFileSync(p,
      JSON.stringify({ role: 'system', content: 'preamble' }) + '\n' +
      JSON.stringify({ role: 'system', content: 'guidance' }) + '\n');
    expect(Conversation.hasReplayableTranscript(p)).toBe(false);
  });

  it('returns true when the transcript has at least one replayable turn', () => {
    const dir = mkdtempSync(join(tmpdir(), 'p3-'));
    const p = join(dir, 't.jsonl');
    writeFileSync(p,
      JSON.stringify({ role: 'system', content: 'preamble' }) + '\n' +
      JSON.stringify({ role: 'user', content: 'do X' }) + '\n');
    expect(Conversation.hasReplayableTranscript(p)).toBe(true);
  });
});
