import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { EventEmitter } from 'events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ToolContext } from './core.js';

const { spawnMock } = vi.hoisted(() => ({
  spawnMock: vi.fn(),
}));

vi.mock('child_process', () => ({
  spawn: spawnMock,
}));

import { executeTool, _resetVersionCheck, inferMediaExtension } from './x.js';

function makeWorkspace(): string {
  return fs.mkdtempSync(path.join(tmpdir(), 'maestro-x-'));
}

function makeContext(workspacePath: string, editAllowed: boolean = false): ToolContext {
  return {
    workspacePath,
    editAllowed,
    toolsConfig: {
      xCliCommand: ['twitter'],
      xAuthToken: 'auth-token',
      xCt0: 'ct0-token',
      xTimeout: 5,
    },
  };
}

function makeSpawnResult(options: { stdout?: string; stderr?: string; exitCode?: number; error?: Error }) {
  const child = new EventEmitter() as EventEmitter & {
    stdout: EventEmitter;
    stderr: EventEmitter;
    kill: ReturnType<typeof vi.fn>;
  };
  child.stdout = new EventEmitter();
  child.stderr = new EventEmitter();
  child.kill = vi.fn();

  setTimeout(() => {
    if (options.error) {
      child.emit('error', options.error);
      return;
    }
    if (options.stdout) child.stdout.emit('data', options.stdout);
    if (options.stderr) child.stderr.emit('data', options.stderr);
    child.emit('close', options.exitCode ?? 0);
  }, 0);

  return child;
}

describe('x tools', () => {
  let workspacePath = '';

  afterEach(() => {
    if (workspacePath) {
      fs.rmSync(workspacePath, { recursive: true, force: true });
      workspacePath = '';
    }
    spawnMock.mockReset();
    _resetVersionCheck();
    vi.restoreAllMocks();
  });

  it('runs XSearch with yaml output and optional save path', async () => {
    workspacePath = makeWorkspace();
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' }))  // version check
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'items:\n  - id: 1\n' }));       // actual call

    const result = await executeTool('XSearch', {
      query: 'llama.cpp thinking mode',
      limit: 5,
      output_path: 'output/x/search.yaml',
    }, makeContext(workspacePath, true));

    expect(result).not.toBeNull();
    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('items:');
    expect(result?.output).toContain('Saved to output/x/search.yaml');
    // 2nd call is the actual search (1st is version check)
    expect(spawnMock).toHaveBeenNthCalledWith(2, 'twitter', ['search', 'llama.cpp thinking mode', '-t', 'Latest', '--max', '5', '--yaml'], expect.objectContaining({
      cwd: workspacePath,
      env: expect.objectContaining({
        TWITTER_AUTH_TOKEN: 'auth-token',
        TWITTER_CT0: 'ct0-token',
      }),
    }));
    expect(fs.readFileSync(path.join(workspacePath, 'output', 'x', 'search.yaml'), 'utf-8')).toContain('items:');
    const historyPath = path.join(workspacePath, 'logs', 'x-cli-history.jsonl');
    expect(fs.readFileSync(historyPath, 'utf-8')).toContain('"tool":"XSearch"');
  });

  it('runs XUserPosts with full text flag', async () => {
    workspacePath = makeWorkspace();
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' }))
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'items:\n  - text: hello\n' }));

    const result = await executeTool('XUserPosts', {
      username: 'openai',
      full_text: true,
      compact: true,
    }, makeContext(workspacePath));

    expect(result?.isError).toBe(false);
    expect(spawnMock).toHaveBeenNthCalledWith(2, 'twitter', ['user-posts', 'openai', '--max', '20', '--yaml', '--full-text', '--compact'], expect.any(Object));
  });

  it('returns a helpful error when twitter-cli is missing', async () => {
    workspacePath = makeWorkspace();
    const enoent = new Error('spawn twitter ENOENT');
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ error: enoent }))   // version check fails
      .mockReturnValueOnce(makeSpawnResult({ error: enoent }));  // actual call fails

    const result = await executeTool('XPostDetail', {
      tweet: 'https://x.com/example/status/123',
    }, makeContext(workspacePath));

    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('Install twitter-cli');
  });

  it('rejects output_path in read-only movements', async () => {
    workspacePath = makeWorkspace();

    const result = await executeTool('XSearch', {
      query: 'openai',
      output_path: 'output/x/openai.yaml',
    }, makeContext(workspacePath));

    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('edit-enabled movement');
    expect(spawnMock).not.toHaveBeenCalled();
  });
});

describe('inferMediaExtension', () => {
  it('uses path extension when present', () => {
    expect(inferMediaExtension('https://pbs.twimg.com/media/abc.jpg')).toBe('.jpg');
    expect(inferMediaExtension('https://video.twimg.com/clip.mp4')).toBe('.mp4');
  });

  it('honors ?format= query for pbs.twimg URLs', () => {
    expect(inferMediaExtension('https://pbs.twimg.com/media/abc?format=png&name=large')).toBe('.png');
  });

  it('falls back to .bin for unrecognizable URLs', () => {
    expect(inferMediaExtension('https://example.com/path/no-ext')).toBe('.bin');
    expect(inferMediaExtension('not-a-url')).toBe('.bin');
  });
});

// ---- Media download integration via XPostDetail ----

describe('XPostDetail media download', () => {
  let workspacePath = '';
  let fetchSpy: ReturnType<typeof vi.spyOn> | null = null;

  afterEach(() => {
    if (workspacePath) {
      fs.rmSync(workspacePath, { recursive: true, force: true });
      workspacePath = '';
    }
    spawnMock.mockReset();
    _resetVersionCheck();
    fetchSpy?.mockRestore();
    fetchSpy = null;
    vi.restoreAllMocks();
  });

  function mockFetch(buf: Buffer, contentLength?: number) {
    fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
      return {
        ok: true,
        status: 200,
        headers: {
          get: (h: string) => h.toLowerCase() === 'content-length'
            ? String(contentLength ?? buf.byteLength)
            : null,
        },
        arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer,
      } as unknown as Response;
    });
  }

  function ctxWithMedia(workspacePath: string, overrides: Partial<ToolContext['toolsConfig']> = {}): ToolContext {
    return {
      workspacePath,
      editAllowed: false,
      toolsConfig: {
        xCliCommand: ['twitter'],
        xAuthToken: 'auth-token',
        xCt0: 'ct0-token',
        xTimeout: 5,
        ...overrides,
      },
    };
  }

  const PHOTO_TWEET_YAML = `ok: true
data:
  - id: '111'
    text: hello
    media:
      - type: photo
        url: https://pbs.twimg.com/media/abc.jpg?name=small
`;

  it('downloads photo media to logs/x-media/{id}/0.jpg and adds localPath', async () => {
    workspacePath = makeWorkspace();
    const buf = Buffer.from('fake-image-bytes');
    mockFetch(buf);
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' }))
      .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML }));

    const result = await executeTool('XPostDetail', { tweet: '111' }, ctxWithMedia(workspacePath));

    expect(result?.isError).toBe(false);
    const savedPath = path.join(workspacePath, 'logs', 'x-media', '111', '0.jpg');
    expect(fs.existsSync(savedPath)).toBe(true);
    expect(fs.readFileSync(savedPath).equals(buf)).toBe(true);
    expect(result?.output).toContain('localPath: logs/x-media/111/0.jpg');
    expect(result?.output).toContain('bytes: ' + buf.byteLength);
  });

  it('skips download entirely when xDownloadMedia=never', async () => {
    workspacePath = makeWorkspace();
    fetchSpy = vi.spyOn(globalThis, 'fetch');
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' }))
      .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML }));

    const result = await executeTool('XPostDetail', { tweet: '111' },
      ctxWithMedia(workspacePath, { xDownloadMedia: 'never' }));

    expect(result?.isError).toBe(false);
    expect(fetchSpy).not.toHaveBeenCalled();
    expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media'))).toBe(false);
    expect(result?.output).not.toContain('localPath:');
  });

  it('aborts media fetch when CDN hangs past xMediaFetchTimeoutSeconds', async () => {
    workspacePath = makeWorkspace();
    // fetch() that respects AbortSignal: rejects with a TimeoutError-like
    // error when the signal fires. Simulates a slow CDN that never sends
    // headers. This is the safety net that prevents a single stuck image
    // from blocking the entire tool call.
    fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => {
      const signal = (init as RequestInit | undefined)?.signal as AbortSignal | undefined;
      return new Promise((_resolve, reject) => {
        if (!signal) return; // shouldn't happen — guard against test regression
        signal.addEventListener('abort', () => {
          const err = new Error('The operation was aborted');
          (err as { name?: string }).name = 'TimeoutError';
          reject(err);
        });
      });
    });
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' }))
      .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML }));

    // Use a very short timeout (50ms) so the test doesn't actually wait 15s.
    const result = await executeTool('XPostDetail', { tweet: '111' },
      ctxWithMedia(workspacePath, { xMediaFetchTimeoutSeconds: 0.05 }));

    // Tool returns success — text content is preserved, only the image was lost.
    // This is the desired graceful-degradation behaviour: text-and-metadata
    // still flow to the LLM even when a single asset can't be fetched.
    expect(result?.isError).toBe(false);
    expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media', '111', '0.jpg'))).toBe(false);
    expect(result?.output).not.toContain('localPath:');
  });

  it('skips media exceeding size cap (content-length)', async () => {
    workspacePath = makeWorkspace();
    const buf = Buffer.alloc(10);
    // 報告 content-length は cap (1MB → 1*1024*1024 = 1048576) を超える 100MB
    mockFetch(buf, 100 * 1024 * 1024);
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' }))
      .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML }));

    const result = await executeTool('XPostDetail', { tweet: '111' },
      ctxWithMedia(workspacePath, { xMediaMaxMb: 1 }));

    expect(result?.isError).toBe(false);
    // ファイルは保存されない
    expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media', '111', '0.jpg'))).toBe(false);
    expect(result?.output).not.toContain('localPath:');
  });

  it('downloads video poster only in thumbnail mode (default)', async () => {
    workspacePath = makeWorkspace();
    mockFetch(Buffer.from('poster'));
    const VIDEO_YAML = `ok: true
data:
  - id: '222'
    text: video
    media:
      - type: video
        url: https://pbs.twimg.com/ext_tw_video_thumb/poster.jpg
        variants:
          - bitrate: 832000
            contentType: video/mp4
            url: https://video.twimg.com/lo.mp4
          - bitrate: 2176000
            contentType: video/mp4
            url: https://video.twimg.com/hi.mp4
`;
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' }))
      .mockReturnValueOnce(makeSpawnResult({ stdout: VIDEO_YAML }));

    const result = await executeTool('XPostDetail', { tweet: '222' }, ctxWithMedia(workspacePath));

    expect(result?.isError).toBe(false);
    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const url = (fetchSpy!.mock.calls[0]![0] as string).toString();
    expect(url).toContain('poster.jpg');
    expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media', '222', '0.jpg'))).toBe(true);
  });

  it('downloads highest-bitrate mp4 in video=full mode', async () => {
    workspacePath = makeWorkspace();
    mockFetch(Buffer.from('mp4-bytes'));
    const VIDEO_YAML = `ok: true
data:
  - id: '333'
    media:
      - type: video
        url: https://pbs.twimg.com/ext_tw_video_thumb/poster.jpg
        variants:
          - bitrate: 832000
            contentType: video/mp4
            url: https://video.twimg.com/lo.mp4
          - bitrate: 2176000
            contentType: video/mp4
            url: https://video.twimg.com/hi.mp4
`;
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' }))
      .mockReturnValueOnce(makeSpawnResult({ stdout: VIDEO_YAML }));

    const result = await executeTool('XPostDetail', { tweet: '333' },
      ctxWithMedia(workspacePath, { xDownloadVideo: 'full' }));

    expect(result?.isError).toBe(false);
    const url = (fetchSpy!.mock.calls[0]![0] as string).toString();
    expect(url).toBe('https://video.twimg.com/hi.mp4');
    expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media', '333', '0.mp4'))).toBe(true);
  });

  it('skips video entirely when xDownloadVideo=never', async () => {
    workspacePath = makeWorkspace();
    fetchSpy = vi.spyOn(globalThis, 'fetch');
    const VIDEO_YAML = `ok: true
data:
  - id: '444'
    media:
      - type: video
        url: https://pbs.twimg.com/poster.jpg
`;
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' }))
      .mockReturnValueOnce(makeSpawnResult({ stdout: VIDEO_YAML }));

    const result = await executeTool('XPostDetail', { tweet: '444' },
      ctxWithMedia(workspacePath, { xDownloadVideo: 'never' }));

    expect(result?.isError).toBe(false);
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it('is idempotent: re-running does not re-fetch existing files', async () => {
    workspacePath = makeWorkspace();
    const buf = Buffer.from('cached');
    mockFetch(buf);
    spawnMock
      .mockReturnValueOnce(makeSpawnResult({ stdout: 'twitter, version 0.8.5\n' }))
      .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML }))
      .mockReturnValueOnce(makeSpawnResult({ stdout: PHOTO_TWEET_YAML })); // 2 回目

    await executeTool('XPostDetail', { tweet: '111' }, ctxWithMedia(workspacePath));
    await executeTool('XPostDetail', { tweet: '111' }, ctxWithMedia(workspacePath));

    expect(fetchSpy).toHaveBeenCalledTimes(1); // 1 回目だけ実 fetch、2 回目は既存ファイル流用
  });
});

// ---- XFetchCardMedia tool (opt-in Playwright fallback for quiz/poll cards) ----

describe('XFetchCardMedia tool', () => {
  let workspacePath = '';
  let fetchSpy: ReturnType<typeof vi.spyOn> | null = null;

  afterEach(() => {
    if (workspacePath) {
      fs.rmSync(workspacePath, { recursive: true, force: true });
      workspacePath = '';
    }
    fetchSpy?.mockRestore();
    fetchSpy = null;
    vi.doUnmock('./browser.js');
    vi.resetModules();
    vi.restoreAllMocks();
  });

  function ctxWithMedia(workspacePath: string): ToolContext {
    return {
      workspacePath,
      editAllowed: false,
      toolsConfig: {
        xCliCommand: ['twitter'],
        xAuthToken: 'auth-token',
        xCt0: 'ct0-token',
        xTimeout: 5,
      },
    };
  }

  /**
   * Mock the dynamically-imported browser module so the tool's Playwright
   * path returns deterministic URL captures without spinning up Chromium.
   * graphqlUrls flow through the response listener; domUrls flow through
   * page.evaluate() — both code paths in fetchCardMediaFromWebPage.
   */
  function mockBrowserModule(opts: {
    graphqlUrls?: string[];
    domUrls?: string[];
  }): void {
    const responseListeners: Array<(resp: { url: () => string; text: () => Promise<string> }) => void> = [];
    const fakePage = {
      on: vi.fn((event: string, listener: (resp: { url: () => string; text: () => Promise<string> }) => void) => {
        if (event === 'response') responseListeners.push(listener);
      }),
      off: vi.fn(),
      setDefaultTimeout: vi.fn(),
      goto: vi.fn(async () => {
        for (const listener of responseListeners) {
          listener({
            url: () => 'https://x.com/i/api/graphql/abc/TweetDetail',
            text: async () => JSON.stringify({ urls: opts.graphqlUrls ?? [] }),
          });
        }
      }),
      waitForSelector: vi.fn(async () => {}),
      waitForLoadState: vi.fn(async () => {}),
      waitForTimeout: vi.fn(async () => {}),
      evaluate: vi.fn(async () => opts.domUrls ?? []),
      close: vi.fn(async () => {}),
    };
    const fakeContext = {
      addCookies: vi.fn(async () => {}),
      newPage: vi.fn(async () => fakePage),
      close: vi.fn(async () => {}),
    };
    const fakeBrowser = {
      newContext: vi.fn(async () => fakeContext),
    };

    vi.doMock('./browser.js', () => ({
      getCaptchaPoolBrowser: vi.fn(async () => fakeBrowser),
    }));
  }

  function mockImageFetch(buf: Buffer): void {
    fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
      ok: true,
      status: 200,
      headers: {
        get: (h: string) => h.toLowerCase() === 'content-length' ? String(buf.byteLength) : null,
      },
      arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer,
    } as unknown as Response));
  }

  it('downloads card images discovered via DOM scope to logs/x-media/{id}/', async () => {
    workspacePath = makeWorkspace();
    mockBrowserModule({
      // Quiz card with a card_img URL at small size — upgrade should bump to large
      domUrls: ['https://pbs.twimg.com/card_img/9999/quizimg?format=jpg&name=small'],
    });
    mockImageFetch(Buffer.from('card-img-bytes'));

    // Re-import executeTool to pick up the mocked browser module
    const { executeTool: executeToolFresh } = await import('./x.js');
    const result = await executeToolFresh(
      'XFetchCardMedia',
      { tweet: 'https://x.com/someone/status/9999' },
      ctxWithMedia(workspacePath),
    );

    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('saved 1 image(s)');
    const saved = path.join(workspacePath, 'logs', 'x-media', '9999', '0.jpg');
    expect(fs.existsSync(saved)).toBe(true);
    // Verify upgradePbsUrl normalized to name=large before fetch
    const fetchedUrl = (fetchSpy!.mock.calls[0]![0] as string).toString();
    expect(fetchedUrl).toContain('name=large');
  });

  it('returns "no card media found" gracefully when both graphql and DOM yield zero URLs', async () => {
    workspacePath = makeWorkspace();
    mockBrowserModule({ domUrls: [], graphqlUrls: [] });
    fetchSpy = vi.spyOn(globalThis, 'fetch');

    const { executeTool: executeToolFresh } = await import('./x.js');
    const result = await executeToolFresh(
      'XFetchCardMedia',
      { tweet: '12345' },
      ctxWithMedia(workspacePath),
    );

    expect(result?.isError).toBe(false);
    expect(result?.output).toContain('no card media found');
    // Graceful exit: no fetch, no dir creation
    expect(fetchSpy).not.toHaveBeenCalled();
    expect(fs.existsSync(path.join(workspacePath, 'logs', 'x-media'))).toBe(false);
  });

  it('rejects malformed tweet input without launching browser', async () => {
    workspacePath = makeWorkspace();
    mockBrowserModule({ domUrls: ['will-not-be-called'] });
    const { executeTool: executeToolFresh } = await import('./x.js');
    const result = await executeToolFresh(
      'XFetchCardMedia',
      { tweet: 'not-a-url-or-id' },
      ctxWithMedia(workspacePath),
    );
    expect(result?.isError).toBe(true);
    expect(result?.output).toContain('could not parse');
  });
});
