import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { executeTool, TOOL_DEFS } from './task-conversation.js';
import type { ToolContext, TaskConversationComment, TaskConversationIO } from './core.js';
import { Repository } from '../../db/repository.js';

let tempDir = '';

function writeTranscript(name: string, lines: object[]): string {
  const p = join(tempDir, name);
  writeFileSync(p, lines.map((l) => JSON.stringify({ ...l, _meta: { ts: Date.now() } })).join('\n') + '\n');
  return p;
}

function makeCtx(io: TaskConversationIO | undefined): ToolContext {
  return { workspacePath: tempDir, taskId: '1', taskConversation: io } as unknown as ToolContext;
}

function makeIO(comments: TaskConversationComment[], transcriptPath?: string): TaskConversationIO {
  return { listComments: async () => comments, transcriptPath };
}

const commentsA: TaskConversationComment[] = [
  { id: 10, author: 'user', kind: 'request', body: 'Please keep the existing auth flow unchanged.', createdAt: '2026-06-30T10:00:00Z' },
  { id: 11, author: 'agent', kind: 'progress', body: 'Inspected the login module.', createdAt: '2026-06-30T10:05:00Z' },
  { id: 12, author: 'user', kind: 'interjection', body: 'Do not rewrite the unrelated UI components.', createdAt: '2026-06-30T10:10:00Z' },
];

beforeEach(() => {
  tempDir = mkdtempSync(join(tmpdir(), 'maestro-tc-'));
});
afterEach(() => {
  rmSync(tempDir, { recursive: true, force: true });
});

describe('TOOL_DEFS', () => {
  it('registers both conversation tools', () => {
    expect(TOOL_DEFS.SearchTaskConversation).toBeDefined();
    expect(TOOL_DEFS.ReadTaskConversation).toBeDefined();
  });
});

describe('SearchTaskConversation', () => {
  it('finds old user comments by keyword (case-insensitive)', async () => {
    const ctx = makeCtx(makeIO(commentsA));
    const res = await executeTool('SearchTaskConversation', { query: 'AUTH flow', source: 'comments' }, ctx);
    expect(res!.isError).toBe(false);
    expect(res!.output).toContain('comment:10');
    expect(res!.output).not.toContain('comment:11'); // no match
    expect(res!.output).not.toContain('comment:12');
  });

  it('finds transcript entries when transcript.jsonl exists, with stable refs', async () => {
    const tp = writeTranscript('transcript.jsonl', [
      { role: 'system', content: 'preamble' },
      { role: 'user', content: 'Constraint: never touch the payment code.' },
      { role: 'assistant', content: 'Understood, I will avoid the payment module.' },
    ]);
    const ctx = makeCtx(makeIO([], tp));
    const res = await executeTool('SearchTaskConversation', { query: 'payment', source: 'transcript' }, ctx);
    expect(res!.isError).toBe(false);
    expect(res!.output).toContain('transcript:1');
    expect(res!.output).toContain('transcript:2');
  });

  it('filters by author', async () => {
    const ctx = makeCtx(makeIO(commentsA));
    const res = await executeTool('SearchTaskConversation', { query: 'the', author: 'user' }, ctx);
    expect(res!.output).toContain('comment:10');
    expect(res!.output).toContain('comment:12');
    expect(res!.output).not.toContain('comment:11');
  });

  it('caps results at the limit and truncates excerpts to a bounded length', async () => {
    const many: TaskConversationComment[] = Array.from({ length: 30 }, (_, i) => ({
      id: i + 1,
      author: 'user',
      kind: 'comment',
      body: 'needle ' + 'x'.repeat(500),
      createdAt: '2026-06-30T10:00:00Z',
    }));
    const ctx = makeCtx(makeIO(many));
    const res = await executeTool('SearchTaskConversation', { query: 'needle', limit: 3 }, ctx);
    const refCount = (res!.output.match(/comment:\d+/g) ?? []).length;
    expect(refCount).toBe(3);
    // No single line should carry the full 500-char body.
    for (const line of res!.output.split('\n')) {
      expect(line.length).toBeLessThan(260);
    }
    expect(res!.output).toContain('…');
  });

  it('rejects an empty query', async () => {
    const ctx = makeCtx(makeIO(commentsA));
    const res = await executeTool('SearchTaskConversation', { query: '   ' }, ctx);
    expect(res!.isError).toBe(true);
  });

  it('a kind filter excludes the transcript source entirely', async () => {
    const tp = writeTranscript('transcript.jsonl', [{ role: 'user', content: 'needle in transcript' }]);
    const ctx = makeCtx(makeIO([{ id: 1, author: 'user', kind: 'request', body: 'needle in comment', createdAt: '2026-06-30T10:00:00Z' }], tp));
    const res = await executeTool('SearchTaskConversation', { query: 'needle', kind: 'request' }, ctx);
    expect(res!.output).toContain('comment:1');
    expect(res!.output).not.toContain('transcript:'); // kind is a comment concept
  });

  it('exposes no transcript when the transcript path is unbound (security fallback)', async () => {
    // The worker binds transcriptPath only to a per-task runtimeDir; when it is
    // unset the transcript source must yield nothing rather than risk a
    // shared-workspace leak. Comments still work.
    const ctx = makeCtx(makeIO([{ id: 1, author: 'user', kind: 'request', body: 'needle here', createdAt: '2026-06-30T10:00:00Z' }], undefined));
    const res = await executeTool('SearchTaskConversation', { query: 'needle', source: 'transcript' }, ctx);
    expect(res!.isError).toBe(false);
    expect(res!.output).not.toContain('transcript:');
  });

  it('skips malformed transcript lines instead of crashing', async () => {
    const p = join(tempDir, 'transcript.jsonl');
    writeFileSync(p, ['{ not json', JSON.stringify({ role: 'user', content: 'needle ok' })].join('\n') + '\n');
    const ctx = makeCtx(makeIO([], p));
    const res = await executeTool('SearchTaskConversation', { query: 'needle', source: 'transcript' }, ctx);
    expect(res!.isError).toBe(false);
    expect(res!.output).toContain('transcript:1'); // index 0 was the malformed line
  });

  it('caps limit at 50 even when a larger value is requested', async () => {
    const many: TaskConversationComment[] = Array.from({ length: 80 }, (_, i) => ({
      id: i + 1, author: 'user', kind: 'comment', body: 'needle', createdAt: '2026-06-30T10:00:00Z',
    }));
    const ctx = makeCtx(makeIO(many));
    const res = await executeTool('SearchTaskConversation', { query: 'needle', limit: 999 }, ctx);
    const refCount = (res!.output.match(/comment:\d+/g) ?? []).length;
    expect(refCount).toBe(50);
  });

  it('degrades to a friendly non-error message when no task context is bound', async () => {
    const ctx = makeCtx(undefined);
    const res = await executeTool('SearchTaskConversation', { query: 'x' }, ctx);
    expect(res!.isError).toBe(false);
    expect(res!.output.length).toBeGreaterThan(0);
  });
});

describe('SearchTaskConversation — AND search (space-separated tokens)', () => {
  it('matches when all tokens appear even if non-contiguous', async () => {
    // comment:10 = 'Please keep the existing auth flow unchanged.'
    // 'auth unchanged' is not a contiguous substring, but both tokens appear.
    const ctx = makeCtx(makeIO(commentsA));
    const res = await executeTool('SearchTaskConversation', { query: 'auth unchanged', source: 'comments' }, ctx);
    expect(res!.isError).toBe(false);
    expect(res!.output).toContain('comment:10');
  });

  it('does not match when tokens are split across different entries (AND, not OR)', async () => {
    // 'login' appears only in comment:11, 'components' only in comment:12.
    const ctx = makeCtx(makeIO(commentsA));
    const res = await executeTool('SearchTaskConversation', { query: 'login components', source: 'comments' }, ctx);
    expect(res!.output).not.toContain('comment:11');
    expect(res!.output).not.toContain('comment:12');
    expect(res!.output).toContain('見つかりませんでした');
  });

  it('applies AND matching to the transcript source too', async () => {
    const tp = writeTranscript('transcript.jsonl', [
      { role: 'user', content: 'Constraint: never touch the payment code.' },
      { role: 'assistant', content: 'I refactored the payment module docs.' },
    ]);
    const ctx = makeCtx(makeIO([], tp));
    const res = await executeTool('SearchTaskConversation', { query: 'payment constraint', source: 'transcript' }, ctx);
    expect(res!.output).toContain('transcript:0');
    expect(res!.output).not.toContain('transcript:1');
  });

  it('treats a full-width (ideographic) space as a token separator', async () => {
    const ctx = makeCtx(makeIO(commentsA));
    const res = await executeTool('SearchTaskConversation', { query: 'auth　unchanged', source: 'comments' }, ctx);
    expect(res!.output).toContain('comment:10');
  });

  it('centers the excerpt on the first token hit when the full query never appears contiguously', async () => {
    const body = 'x'.repeat(300) + ' alpha middle beta ' + 'y'.repeat(300);
    const ctx = makeCtx(makeIO([{ id: 1, author: 'user', kind: 'comment', body, createdAt: '2026-06-30T10:00:00Z' }]));
    const res = await executeTool('SearchTaskConversation', { query: 'alpha beta' }, ctx);
    expect(res!.output).toContain('comment:1');
    expect(res!.output).toContain('alpha'); // excerpt must show the hit, not the head of the body
  });
});

// P0/P1 (review): the REAL scoping guarantee comes from
// repo.makeTaskConversationIO(taskId) → listLocalTaskComments(taskId) WHERE
// task_id = ?. Exercise it end-to-end with a real temp-DB Repository and two
// tasks; task A's tool must not see task B's comment. This has teeth: rebinding
// the IO to the wrong taskId turns it red.
describe('SearchTaskConversation — real repository task scoping (integration)', () => {
  it('cannot see another task\'s comments through the real per-task IO', async () => {
    const repo = new Repository(join(tempDir, 'scope.db'));
    const a = await repo.createLocalTask({ title: 'A', body: 'a' });
    const b = await repo.createLocalTask({ title: 'B', body: 'b' });
    await repo.addLocalTaskComment(a.id, 'user', 'Task A: keep the AUTHFLOW unchanged', 'request');
    await repo.addLocalTaskComment(b.id, 'user', 'Task B AUTHFLOW SECRET must not leak', 'request');

    const ioA = repo.makeTaskConversationIO(a.id);
    const ctxA = { workspacePath: tempDir, taskId: String(a.id), taskConversation: ioA } as unknown as ToolContext;
    const res = await executeTool('SearchTaskConversation', { query: 'AUTHFLOW', source: 'comments' }, ctxA);
    expect(res!.output).toContain('keep the AUTHFLOW');
    expect(res!.output).not.toContain('SECRET'); // task B must never leak into task A
  });
});

describe('ReadTaskConversation', () => {
  it('returns bounded neighbors around a comment ref', async () => {
    const ctx = makeCtx(makeIO(commentsA));
    const res = await executeTool('ReadTaskConversation', { ref: 'comment:11', before: 1, after: 1 }, ctx);
    expect(res!.isError).toBe(false);
    expect(res!.output).toContain('comment:10');
    expect(res!.output).toContain('comment:11');
    expect(res!.output).toContain('comment:12');
  });

  it('clamps before/after to at most 5 each', async () => {
    const tp = writeTranscript('transcript.jsonl', Array.from({ length: 40 }, (_, i) => ({ role: 'user', content: `line ${i}` })));
    const ctx = makeCtx(makeIO([], tp));
    const res = await executeTool('ReadTaskConversation', { ref: 'transcript:20', before: 100, after: 100 }, ctx);
    const refCount = (res!.output.match(/transcript:\d+/g) ?? []).length;
    expect(refCount).toBeLessThanOrEqual(11); // 5 before + self + 5 after
  });

  it('rejects a malformed ref', async () => {
    const ctx = makeCtx(makeIO(commentsA));
    const res = await executeTool('ReadTaskConversation', { ref: 'bogus' }, ctx);
    expect(res!.isError).toBe(true);
  });

  it('reports a well-formed but non-existent ref as an error', async () => {
    const ctx = makeCtx(makeIO(commentsA));
    const res = await executeTool('ReadTaskConversation', { ref: 'comment:999' }, ctx);
    expect(res!.isError).toBe(true);
    expect(res!.output).toContain('999');
  });

  it('degrades to a friendly non-error message when no task context is bound', async () => {
    const ctx = makeCtx(undefined);
    const res = await executeTool('ReadTaskConversation', { ref: 'comment:10' }, ctx);
    expect(res!.isError).toBe(false);
    expect(res!.output.length).toBeGreaterThan(0);
  });
});

describe('sub-task transcript-only recall', () => {
  it('searches the transcript while comments stay empty', async () => {
    const tp = writeTranscript('transcript.jsonl', [
      { role: 'user', content: 'Sub-task brief: summarize the quarterly churn drivers.' },
      { role: 'assistant', content: 'Noted churn driver: onboarding friction.' },
    ]);
    const ctx = makeCtx(makeIO([], tp)); // transcript-only shape (no comments)
    const res = await executeTool('SearchTaskConversation', { query: 'churn', source: 'both' }, ctx);
    expect(res!.isError).toBe(false);
    expect(res!.output).toContain('transcript:0');
    expect(res!.output).toContain('transcript:1');
  });

  it('degrades gracefully when a comment ref is read but no comments exist', async () => {
    const tp = writeTranscript('transcript.jsonl', [{ role: 'user', content: 'hello' }]);
    const ctx = makeCtx(makeIO([], tp));
    const res = await executeTool('ReadTaskConversation', { ref: 'comment:1' }, ctx);
    expect(res!.isError).toBe(true);
    expect(res!.output).toContain('見つかりません');
  });
});
