import { describe, expect, it, beforeEach } from 'vitest';
import { TOOL_DEFS, executeTool, normalizeFileUrlForWorkspace } from './browser.js';
import { recorder } from '../browser-recorder.js';

describe('browser TOOL_DEFS', () => {
  it('exports BrowseWeb definition', () => {
    expect(TOOL_DEFS).toHaveProperty('BrowseWeb');
  });

  it('does not export BrowserAction (merged into BrowseWeb)', () => {
    expect(TOOL_DEFS).not.toHaveProperty('BrowserAction');
  });

  it('BrowseWeb description mentions session persistence', () => {
    const desc = TOOL_DEFS['BrowseWeb']!.function.description;
    expect(desc).toContain('セッション');
  });

  it('BrowseWeb description mentions actions mode', () => {
    const desc = TOOL_DEFS['BrowseWeb']!.function.description;
    expect(desc).toContain('actions');
  });

  it('BrowseWeb has screenshot parameter', () => {
    const props = TOOL_DEFS['BrowseWeb']!.function.parameters as { properties: Record<string, unknown> };
    expect(props.properties).toHaveProperty('screenshot');
  });

  it('BrowseWeb has actions parameter', () => {
    const props = TOOL_DEFS['BrowseWeb']!.function.parameters as { properties: Record<string, unknown> };
    expect(props.properties).toHaveProperty('actions');
  });

  it('BrowseWeb does not require url (optional in actions mode)', () => {
    const params = TOOL_DEFS['BrowseWeb']!.function.parameters as { required?: string[] };
    expect(params.required).toBeUndefined();
  });
});

describe('executeTool', () => {
  it('returns null for unknown tool names', async () => {
    const result = await executeTool('UnknownTool', {}, {
      workspacePath: '/tmp/test',
      editAllowed: false,
    });
    expect(result).toBeNull();
  });

  it('BrowseWeb rejects invalid URLs', async () => {
    const result = await executeTool('BrowseWeb', { url: 'not-a-url' }, {
      workspacePath: '/tmp/test',
      editAllowed: false,
    });
    expect(result).not.toBeNull();
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('BrowseWeb error');
  });

  it('BrowseWeb blocks SSRF to localhost', async () => {
    const result = await executeTool('BrowseWeb', { url: 'http://localhost:8080/admin' }, {
      workspacePath: '/tmp/test',
      editAllowed: false,
    });
    expect(result).not.toBeNull();
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('SSRF');
  });

  it('BrowseWeb blocks file URLs outside workspace', async () => {
    const result = await executeTool('BrowseWeb', { url: 'file:///etc/passwd' }, {
      workspacePath: '/tmp/test',
      editAllowed: false,
    });
    expect(result).not.toBeNull();
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('file:// URL is only allowed within workspace');
  });

  it('resolves workspace-relative path to file:// URL inside workspace', () => {
    const result = normalizeFileUrlForWorkspace('output/viewer.html', '/tmp/job-123');

    expect(result).toEqual({
      url: 'file:///tmp/job-123/output/viewer.html',
    });
  });

  it('rejects workspace-relative path that escapes workspace via ..', () => {
    const result = normalizeFileUrlForWorkspace('../etc/passwd', '/tmp/job-123');

    expect(result).toHaveProperty('error');
  });

  it('rejects absolute filesystem path passed as bare url', () => {
    const result = normalizeFileUrlForWorkspace('/etc/passwd', '/tmp/job-123');

    expect(result).toHaveProperty('error');
  });

  it('passes through https URLs unchanged', () => {
    const result = normalizeFileUrlForWorkspace('https://example.com/path?q=1', '/tmp/job-123');

    expect(result).toEqual({ url: 'https://example.com/path?q=1' });
  });

  it('still accepts legacy file:///workspace/... for backwards compatibility', () => {
    const result = normalizeFileUrlForWorkspace('file:///workspace/output/viewer.html', '/tmp/job-123');

    expect(result).toEqual({
      url: 'file:///tmp/job-123/output/viewer.html',
    });
  });

  it('rejects legacy /workspace traversal attempts', () => {
    const result = normalizeFileUrlForWorkspace('file:///workspace/../etc/passwd', '/tmp/job-123');

    expect(result).toHaveProperty('error');
  });

  it('BrowseWeb rejects when neither url nor actions provided', async () => {
    const result = await executeTool('BrowseWeb', {}, {
      workspacePath: '/tmp/test',
      editAllowed: false,
    });
    expect(result).not.toBeNull();
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('url または actions');
  });

  it('BrowseWeb rejects empty actions array', async () => {
    const result = await executeTool('BrowseWeb', { actions: [] }, {
      workspacePath: '/tmp/test',
      editAllowed: false,
    });
    expect(result).not.toBeNull();
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('url または actions');
  });
});

describe('BrowseWeb auth expiry helper export', () => {
  it('exports detectAuthExpiry as runAuthCheck', async () => {
    const mod = await import('./browser.js');
    expect(typeof mod.runAuthCheck).toBe('function');
    const verdict = mod.runAuthCheck({
      profile: { loggedInSelector: null, loginUrlPatterns: [] },
      finalUrl: 'https://x.com/api/me',
      statusCode: 401,
      loggedInSelectorPresent: false,
    });
    expect(verdict).toEqual({ expired: true, reason: 'HTTP 401' });
  });
});

describe('BrowseWeb download helpers', () => {
  it('sanitizeDownloadFilename strips path traversal', async () => {
    const { sanitizeDownloadFilename } = await import('./browser.js');
    expect(sanitizeDownloadFilename('../../etc/passwd')).toBe('passwd');
    expect(sanitizeDownloadFilename('a/b/c.txt')).toBe('c.txt');
  });

  it('sanitizeDownloadFilename replaces forbidden chars and whitespace with _', async () => {
    const { sanitizeDownloadFilename } = await import('./browser.js');
    expect(sanitizeDownloadFilename('foo<bar>:baz?.csv')).toBe('foo_bar__baz_.csv');
    expect(sanitizeDownloadFilename('hello world.pdf')).toBe('hello_world.pdf');
  });

  it('sanitizeDownloadFilename preserves hyphens and parens and Japanese', async () => {
    const { sanitizeDownloadFilename } = await import('./browser.js');
    expect(sanitizeDownloadFilename('report-2026-05.csv')).toBe('report-2026-05.csv');
    expect(sanitizeDownloadFilename('レポート(最新).pdf')).toBe('レポート(最新).pdf');
  });

  it('sanitizeDownloadFilename returns "download" for empty / null input', async () => {
    const { sanitizeDownloadFilename } = await import('./browser.js');
    expect(sanitizeDownloadFilename('')).toBe('download');
    expect(sanitizeDownloadFilename(null)).toBe('download');
    expect(sanitizeDownloadFilename(undefined)).toBe('download');
  });

  it('pickUniqueOutputPath returns the original path when no collision', async () => {
    const { pickUniqueOutputPath } = await import('./browser.js');
    const { mkdtempSync } = await import('fs');
    const { tmpdir } = await import('os');
    const { join } = await import('path');
    const dir = mkdtempSync(join(tmpdir(), 'crg-bw-dl-'));
    const got = pickUniqueOutputPath(dir, 'foo.csv');
    expect(got).toBe(join(dir, 'output', 'foo.csv'));
  });

  it('pickUniqueOutputPath disambiguates by appending -N', async () => {
    const { pickUniqueOutputPath } = await import('./browser.js');
    const { mkdtempSync, mkdirSync, writeFileSync } = await import('fs');
    const { tmpdir } = await import('os');
    const { join } = await import('path');
    const dir = mkdtempSync(join(tmpdir(), 'crg-bw-dl-'));
    const outDir = join(dir, 'output');
    mkdirSync(outDir, { recursive: true });
    writeFileSync(join(outDir, 'foo.csv'), 'a');
    writeFileSync(join(outDir, 'foo-1.csv'), 'b');
    const got = pickUniqueOutputPath(dir, 'foo.csv');
    expect(got).toBe(join(outDir, 'foo-2.csv'));
  });
});

describe('BrowseWeb recording', () => {
  // These tests drive a real Playwright browser against file:// pages.
  // They need a workspace path that is a real temp directory so that the
  // file:// SSRF allowlist accepts the URLs.
  const TEST_TASK_ID = 'rec-test-task-001';
  const TEST_USER_ID = 'user-rec-test';

  // Reset recorder buffer between tests
  beforeEach(() => {
    recorder.cancel(TEST_TASK_ID);
  });

  it('records click + fill + goto actions when recordTo is set', async () => {
    const { mkdtempSync, writeFileSync, readFileSync, mkdirSync } = await import('fs');
    const { tmpdir } = await import('os');
    const { join } = await import('path');

    // Create a real temp workspace so file:// URLs are allowed
    const workspacePath = mkdtempSync(join(tmpdir(), 'bw-rec-ws-'));
    mkdirSync(join(workspacePath, 'output'), { recursive: true });

    const html = `<!DOCTYPE html><html><body>
      <input id="username" name="username" type="text" />
      <button data-testid="submit">Submit</button>
    </body></html>`;
    const htmlFile = join(workspacePath, 'form.html');
    writeFileSync(htmlFile, html);
    const fileUrl = `file://${htmlFile}`;

    const ctx = {
      workspacePath,
      editAllowed: false,
      taskId: TEST_TASK_ID,
      userId: TEST_USER_ID,
    };

    const result = await executeTool(
      'BrowseWeb',
      {
        recordTo: 'test-recording',
        actions: [
          { type: 'goto', url: fileUrl },
          { type: 'fill', selector: 'input#username', value: 'hello' },
          { type: 'click', selector: 'button[data-testid="submit"]' },
        ],
      },
      ctx,
    );

    // BrowseWeb should succeed
    expect(result).not.toBeNull();
    expect(result!.isError).toBe(false);

    // Buffer should have 3 entries (goto + fill + click)
    expect(recorder.bufferSize(TEST_TASK_ID)).toBe(3);

    // Flush to a temp directory and verify structure
    const tmpRoot = mkdtempSync(join(tmpdir(), 'bw-rec-flush-'));
    mkdirSync(join(tmpRoot, TEST_USER_ID, 'recordings'), { recursive: true });

    const outputPath = recorder.flush(TEST_TASK_ID, tmpRoot, TEST_USER_ID);
    expect(outputPath).not.toBeNull();

    const data = JSON.parse(readFileSync(outputPath!, 'utf-8'));
    expect(data.recordTo).toBe('test-recording');
    expect(Array.isArray(data.actions)).toBe(true);
    expect(data.actions).toHaveLength(3);

    // goto entry: url set, selector undefined
    expect(data.actions[0].type).toBe('goto');
    expect(data.actions[0].url).toBe(fileUrl);
    expect(data.actions[0].selector).toBeUndefined();

    // fill entry: resolved selector (DOM path), not the LLM ref
    expect(data.actions[1].type).toBe('fill');
    expect(typeof data.actions[1].selector).toBe('string');
    expect(data.actions[1].selector!.length).toBeGreaterThan(0);
    expect(data.actions[1].value).toBe('hello');

    // click entry: selector resolved from data-testid (priority-order contract)
    expect(data.actions[2].type).toBe('click');
    expect(data.actions[2].selector).toBe('[data-testid="submit"]');
  }, 30000);

  it('two sequential BrowseWeb calls with same recordTo accumulate actions (idempotent enable)', async () => {
    const { mkdtempSync, writeFileSync, mkdirSync } = await import('fs');
    const { tmpdir } = await import('os');
    const { join } = await import('path');

    const workspacePath = mkdtempSync(join(tmpdir(), 'bw-idem-ws-'));
    mkdirSync(join(workspacePath, 'output'), { recursive: true });

    const html = `<!DOCTYPE html><html><body>
      <input id="q" name="q" type="text" />
      <button data-testid="go">Go</button>
    </body></html>`;
    const htmlFile = join(workspacePath, 'idem.html');
    writeFileSync(htmlFile, html);
    const fileUrl = `file://${htmlFile}`;

    const ctx = {
      workspacePath,
      editAllowed: false,
      taskId: TEST_TASK_ID,
      userId: TEST_USER_ID,
    };

    // First BrowseWeb call — 3 actions
    await executeTool(
      'BrowseWeb',
      {
        recordTo: 'idem-recording',
        actions: [
          { type: 'goto', url: fileUrl },
          { type: 'fill', selector: 'input#q', value: 'first' },
          { type: 'click', selector: 'button[data-testid="go"]' },
        ],
      },
      ctx,
    );
    expect(recorder.bufferSize(TEST_TASK_ID)).toBe(3);

    // Second BrowseWeb call with same recordTo — should NOT reset the buffer
    await executeTool(
      'BrowseWeb',
      {
        recordTo: 'idem-recording',
        actions: [
          { type: 'fill', selector: 'input#q', value: 'second' },
          { type: 'click', selector: 'button[data-testid="go"]' },
        ],
      },
      ctx,
    );

    // Total must be 3 + 2 = 5 (buffer not wiped on second enable)
    expect(recorder.bufferSize(TEST_TASK_ID)).toBe(5);
  }, 30000);

  it('does not record when recordTo is absent', async () => {
    const { mkdtempSync, writeFileSync, mkdirSync } = await import('fs');
    const { tmpdir } = await import('os');
    const { join } = await import('path');

    const workspacePath = mkdtempSync(join(tmpdir(), 'bw-norec-ws-'));
    mkdirSync(join(workspacePath, 'output'), { recursive: true });

    const html = `<!DOCTYPE html><html><body>
      <input name="q" type="text" />
    </body></html>`;
    const htmlFile = join(workspacePath, 'form2.html');
    writeFileSync(htmlFile, html);
    const fileUrl = `file://${htmlFile}`;

    const ctx = {
      workspacePath,
      editAllowed: false,
      taskId: TEST_TASK_ID,
      userId: TEST_USER_ID,
    };

    const result = await executeTool(
      'BrowseWeb',
      {
        // No recordTo
        actions: [
          { type: 'goto', url: fileUrl },
          { type: 'fill', selector: 'input[name="q"]', value: 'test' },
        ],
      },
      ctx,
    );

    expect(result).not.toBeNull();
    expect(result!.isError).toBe(false);

    // Buffer must remain empty since recordTo was not set
    expect(recorder.bufferSize(TEST_TASK_ID)).toBe(0);
  }, 30000);

  it('records goto with url field but no selector', async () => {
    const { mkdtempSync, writeFileSync, readFileSync, mkdirSync } = await import('fs');
    const { tmpdir } = await import('os');
    const { join } = await import('path');

    const workspacePath = mkdtempSync(join(tmpdir(), 'bw-goto-ws-'));
    mkdirSync(join(workspacePath, 'output'), { recursive: true });

    const htmlFile = join(workspacePath, 'simple.html');
    writeFileSync(htmlFile, '<html><body>hello</body></html>');
    const fileUrl = `file://${htmlFile}`;

    const ctx = {
      workspacePath,
      editAllowed: false,
      taskId: TEST_TASK_ID,
      userId: TEST_USER_ID,
    };

    const result = await executeTool(
      'BrowseWeb',
      {
        recordTo: 'goto-only',
        actions: [{ type: 'goto', url: fileUrl }],
      },
      ctx,
    );

    expect(result).not.toBeNull();
    expect(result!.isError).toBe(false);

    expect(recorder.bufferSize(TEST_TASK_ID)).toBe(1);

    const tmpRoot = mkdtempSync(join(tmpdir(), 'bw-rec-goto-'));
    mkdirSync(join(tmpRoot, TEST_USER_ID, 'recordings'), { recursive: true });

    const outputPath = recorder.flush(TEST_TASK_ID, tmpRoot, TEST_USER_ID);
    expect(outputPath).not.toBeNull();

    const data = JSON.parse(readFileSync(outputPath!, 'utf-8'));
    expect(data.actions).toHaveLength(1);
    expect(data.actions[0].type).toBe('goto');
    expect(data.actions[0].url).toBe(fileUrl);
    // goto must NOT set selector
    expect(data.actions[0].selector).toBeUndefined();
  }, 30000);

  it('does not record when taskId or userId is missing even with recordTo set', async () => {
    const { mkdtempSync, writeFileSync, mkdirSync } = await import('fs');
    const { tmpdir } = await import('os');
    const { join } = await import('path');

    const workspacePath = mkdtempSync(join(tmpdir(), 'bw-noctx-ws-'));
    mkdirSync(join(workspacePath, 'output'), { recursive: true });

    const htmlFile = join(workspacePath, 'x.html');
    writeFileSync(htmlFile, '<html><body>x</body></html>');
    const fileUrl = `file://${htmlFile}`;

    // ctx without taskId or userId — recording must be silently skipped
    const ctx = {
      workspacePath,
      editAllowed: false,
      // taskId and userId intentionally omitted
    };

    const result = await executeTool(
      'BrowseWeb',
      {
        recordTo: 'should-not-record',
        actions: [{ type: 'goto', url: fileUrl }],
      },
      ctx,
    );

    expect(result).not.toBeNull();
    // The tool should still succeed (recording is additive, not required)
    expect(result!.isError).toBe(false);
    // recorder was never enabled for any taskId, so 'should-not-record' key has size 0
    expect(recorder.bufferSize('should-not-record')).toBe(0);
    // Also confirm no buffer was created for undefined taskId
    expect(recorder.bufferSize('')).toBe(0);
  }, 30000);
});
