/**
 * read-consolidation.test.ts — pins the "one Read to rule them all" refactor.
 *
 * The former ReadExcel / ReadDocx / ReadPdf / ReadPPTX / ReadMsg tools are
 * merged into a single `Read` that auto-detects the format by extension and
 * dispatches to the (unchanged) office/msg extraction handlers. These tests
 * cover the behaviours the merge must preserve:
 *   - Read dispatches each document format to its extraction handler.
 *   - PDF advanced options (query) pass through Read to the handler verbatim.
 *   - Read on a .msg still performs the attachment-save side effect (and skips
 *     it in a read-only phase).
 *   - Plain text still uses the byte/line Read path.
 *   - Cache routing keys a Read of an office extension into the OFFICE cache
 *     namespace (so sheet/range/query args don't collide), plain text into the
 *     READ namespace, and a .msg read is uncached (side-effectful).
 *   - The 5 old reader names are absent from the presented catalog and the
 *     tool-category map, but ReadToolDoc({ name: "ReadExcel" }) still resolves
 *     to the unified read doc.
 *   - A Read of a .msg is NOT parallel-safe (attachment write side effect),
 *     while a Read of plain text is.
 */
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { afterEach, describe, expect, it } from 'vitest';

import type { ToolCall } from '../../llm/openai-compat.js';
import { executeCoreTools, type ToolContext } from './core.js';
import { executeTool as officeExecuteTool } from './office.js';
import { executeTool as docsExecuteTool } from './docs.js';
import { getToolDefs } from './index.js';
import { getToolCategoryMap, _resetToolCategoryCacheForTests } from './tool-categories.js';
import { routeToolThroughCache } from '../agent-loop/tool-cache-routing.js';
import { canExecuteInParallel } from '../agent-loop/tool-dispatcher.js';
import { ToolResultCache } from '../context/tool-result-cache.js';
import { buildOfficeCacheKey, buildReadCacheKey } from '../context/cache-key.js';

const RETIRED_READERS = ['ReadExcel', 'ReadDocx', 'ReadPdf', 'ReadPPTX', 'ReadMsg'];

const MSG_FIXTURE = path.join(
  path.dirname(fileURLToPath(import.meta.url)),
  '__fixtures__',
  'attachmentFiles.msg',
);

function makeWorkspace(): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'maestro-readcons-'));
}
function ctx(workspacePath: string, editAllowed = true): ToolContext {
  return { workspacePath, editAllowed };
}

const toolCall = (name: string, args: Record<string, unknown>): ToolCall => ({
  id: `tc-${name}`,
  type: 'function',
  function: { name, arguments: JSON.stringify(args) },
});

// Minimal single-page PDF containing `text`. /Length is computed so multi-word
// strings survive intact (needed for the query passthrough test).
function writeMinimalPdf(filePath: string, text: string): void {
  const stream = `BT\n/F1 24 Tf\n100 100 Td\n(${text}) Tj\nET\n`;
  const streamLen = Buffer.byteLength(stream, 'utf-8');
  const pdf = `%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length ${streamLen} >>
stream
${stream}endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
trailer
<< /Root 1 0 R /Size 6 >>
%%EOF
`;
  fs.writeFileSync(filePath, pdf, 'utf-8');
}

describe('Read consolidation — dispatch by extension', () => {
  let ws = '';
  afterEach(() => {
    if (ws) { fs.rmSync(ws, { recursive: true, force: true }); ws = ''; }
  });

  it('dispatches .xlsx to the Excel extraction (real content)', async () => {
    ws = makeWorkspace();
    fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
    const ExcelJS = (await import('exceljs')).default;
    const wb = new ExcelJS.Workbook();
    wb.addWorksheet('Sheet1').addRow(['alpha', 'beta']);
    await wb.xlsx.writeFile(path.join(ws, 'input', 'data.xlsx'));

    const res = await executeCoreTools('Read', { file_path: 'input/data.xlsx' }, ctx(ws));
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('Sheet1');
    expect(res?.output).toContain('alpha');
  });

  it('dispatches .pdf to the PDF extraction (no longer a "use ReadPdf" rejection)', async () => {
    ws = makeWorkspace();
    fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
    writeMinimalPdf(path.join(ws, 'input', 'doc.pdf'), 'hello world');

    const res = await executeCoreTools('Read', { file_path: 'input/doc.pdf' }, ctx(ws));
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('hello world');
  });

  it('passes PDF advanced options (query) through Read to the handler verbatim', async () => {
    ws = makeWorkspace();
    fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
    writeMinimalPdf(path.join(ws, 'input', 'doc.pdf'), 'find KEYWORD here');

    const viaRead = await executeCoreTools(
      'Read',
      { file_path: 'input/doc.pdf', query: 'KEYWORD' },
      ctx(ws),
    );
    const viaHandler = await officeExecuteTool(
      'ReadPdf',
      { path: 'input/doc.pdf', query: 'KEYWORD' },
      ctx(ws),
    );
    expect(viaRead?.output).toEqual(viaHandler?.output);
    expect(viaRead?.isError).toBe(viaHandler?.isError);
  });

  it('performs the .msg attachment-save side effect via Read', async () => {
    ws = makeWorkspace();
    fs.copyFileSync(MSG_FIXTURE, path.join(ws, 'mail.msg'));

    const res = await executeCoreTools('Read', { file_path: 'mail.msg' }, ctx(ws));
    expect(res?.isError).toBeFalsy();
    expect(res?.output).toContain('attachmentFiles');
    // Attachments extracted to input/ (the side effect ReadMsg used to own).
    const inputFiles = fs.existsSync(path.join(ws, 'input'))
      ? fs.readdirSync(path.join(ws, 'input'))
      : [];
    expect(inputFiles.length).toBeGreaterThan(0);
  });

  it('does not save .msg attachments in a read-only phase', async () => {
    ws = makeWorkspace();
    fs.copyFileSync(MSG_FIXTURE, path.join(ws, 'mail.msg'));

    const res = await executeCoreTools('Read', { file_path: 'mail.msg' }, ctx(ws, false));
    expect(res?.isError).toBeFalsy();
    const inputFiles = fs.existsSync(path.join(ws, 'input'))
      ? fs.readdirSync(path.join(ws, 'input'))
      : [];
    expect(inputFiles.length).toBe(0);
  });

  it('still uses the byte/line path for plain text', async () => {
    ws = makeWorkspace();
    fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
    fs.writeFileSync(path.join(ws, 'input', 'notes.txt'), 'line0\nline1\nline2\nline3\n');

    const res = await executeCoreTools('Read', { file_path: 'input/notes.txt', offset: 1, limit: 2 }, ctx(ws));
    expect(res?.isError).toBe(false);
    expect(res?.output).toContain('line1');
    expect(res?.output).toContain('line2');
    expect(res?.output).not.toContain('line0');
  });

  it('still rejects image files (ReadImage is a separate tool)', async () => {
    ws = makeWorkspace();
    fs.writeFileSync(path.join(ws, 'pic.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
    const res = await executeCoreTools('Read', { file_path: 'pic.png' }, ctx(ws));
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('ReadImage');
  });
});

describe('Read consolidation — cache routing by resolved format', () => {
  const WS = '/tmp/ws-readcons';

  it('keys a Read of a .xlsx into the OFFICE cache namespace (captures sheet/range)', () => {
    const cache = new ToolResultCache();
    const route = routeToolThroughCache(
      toolCall('Read', { file_path: 'input/data.xlsx', sheet: 'Q1', range: 'A1:D10' }),
      cache,
      WS,
    );
    expect(route).not.toBeNull();
    expect(route!.cacheKey).toBe(
      buildOfficeCacheKey({
        workspacePath: WS,
        toolName: 'Read',
        filePath: 'input/data.xlsx',
        range: 'range="A1:D10"&sheet="Q1"',
      }),
    );
  });

  it('gives distinct office cache keys for different page_range args', () => {
    const cache = new ToolResultCache();
    const a = routeToolThroughCache(toolCall('Read', { file_path: 'a.pdf', page_range: '1-3' }), cache, WS);
    const b = routeToolThroughCache(toolCall('Read', { file_path: 'a.pdf', page_range: '4-6' }), cache, WS);
    expect(a!.cacheKey).not.toBe(b!.cacheKey);
  });

  it('keys plain text into the READ cache namespace', () => {
    const cache = new ToolResultCache();
    const route = routeToolThroughCache(
      toolCall('Read', { file_path: 'input/notes.txt', offset: 2, limit: 5 }),
      cache,
      WS,
    );
    expect(route!.cacheKey).toBe(
      buildReadCacheKey({ workspacePath: WS, filePath: 'input/notes.txt', offset: 2, limit: 5 }),
    );
  });

  it('does not cache a .msg read (side-effectful)', () => {
    const cache = new ToolResultCache();
    expect(routeToolThroughCache(toolCall('Read', { file_path: 'mail.msg' }), cache, WS)).toBeNull();
  });
});

describe('Read consolidation — old reader names are gone from the surface', () => {
  it('none of the 5 reader names appear in the presented catalog', async () => {
    const defs = await getToolDefs(['Read', ...RETIRED_READERS], true, { vlmEnabled: true });
    const names = new Set(defs.map((d) => d.function.name));
    expect(names.has('Read')).toBe(true);
    for (const retired of RETIRED_READERS) {
      expect(names.has(retired)).toBe(false);
    }
  });

  it('none of the 5 reader names appear in the tool-category map', async () => {
    _resetToolCategoryCacheForTests();
    const map = await getToolCategoryMap();
    for (const retired of RETIRED_READERS) {
      expect(map.has(retired)).toBe(false);
    }
    // Sanity: a still-registered office tool remains categorised.
    expect(map.get('PdfToImages')).toBe('office');
  });

  it('ReadToolDoc({ name: "ReadExcel" }) still resolves to the unified read doc', async () => {
    const res = await docsExecuteTool('ReadToolDoc', { name: 'ReadExcel' }, ctx('/tmp'));
    expect(res).not.toBeNull();
    expect(res?.isError).toBeFalsy();
    expect(res?.output).toContain('# Read');
    expect(res?.output).not.toContain('存在しません');
  });
});

describe('Read consolidation — parallel safety is extension-aware', () => {
  it('a Read of a .msg is NOT parallel-safe (attachment write side effect)', async () => {
    const regularTools = await getToolDefs(['Read'], true, { vlmEnabled: true });
    expect(canExecuteInParallel(toolCall('Read', { file_path: 'mail.msg' }), regularTools)).toBe(false);
  });

  it('a Read of plain text IS parallel-safe', async () => {
    const regularTools = await getToolDefs(['Read'], true, { vlmEnabled: true });
    expect(canExecuteInParallel(toolCall('Read', { file_path: 'notes.txt' }), regularTools)).toBe(true);
  });
});
