import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import YAML from 'yaml';
import { ToolDef } from '../../llm/openai-compat.js';
import { logger } from '../../logger.js';
import { resolveOutputPathWithin, type ToolContext, type ToolResult } from './core.js';
import type { StructuredBlock, XPostItem } from './structured-blocks.js';

const DEFAULT_X_TIMEOUT_SECONDS = 90;
const MIN_RECOMMENDED_VERSION = '0.8.5';

let _versionChecked = false;

/** @internal テスト用リセット */
export function _resetVersionCheck(): void { _versionChecked = false; }

async function checkTwitterCliVersion(command: string[]): Promise<void> {
  if (_versionChecked) return;
  _versionChecked = true;

  try {
    const result = await new Promise<{ stdout: string; exitCode: number | null }>((resolve) => {
      const child = childProcess.spawn(command[0]!, [...command.slice(1), '--version'], {
        stdio: ['ignore', 'pipe', 'ignore'],
      });
      let stdout = '';
      child.stdout.on('data', (chunk: Buffer | string) => { stdout += chunk.toString(); });
      const timer = setTimeout(() => { child.kill('SIGKILL'); resolve({ stdout, exitCode: null }); }, 5000);
      child.on('error', () => { clearTimeout(timer); resolve({ stdout, exitCode: null }); });
      child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, exitCode: code }); });
    });

    const versionMatch = result.stdout.match(/(\d+\.\d+\.\d+)/);
    if (versionMatch) {
      const version = versionMatch[1]!;
      if (compareVersions(version, MIN_RECOMMENDED_VERSION) < 0) {
        logger.warn(`[x-tools] twitter-cli ${version} detected. Version ${MIN_RECOMMENDED_VERSION}+ is recommended. Run: ./scripts/install-twitter-cli.sh --upgrade`);
      } else {
        logger.debug(`[x-tools] twitter-cli ${version} OK`);
      }
    }
  } catch {
    // version check is best-effort
  }
}

function compareVersions(a: string, b: string): number {
  const pa = a.split('.').map(Number);
  const pb = b.split('.').map(Number);
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    const na = pa[i] ?? 0;
    const nb = pb[i] ?? 0;
    if (na !== nb) return na - nb;
  }
  return 0;
}

const XSEARCH_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'XSearch',
    description: 'X / Twitter の投稿を検索する（twitter-cli 経由、認証 Cookie 設定が必要）。詳細は ReadToolDoc({ name: "XSearch" })。',
    parameters: {
      type: 'object',
      properties: {
        query: { type: 'string', description: '検索クエリ' },
        limit: { type: 'number', description: '件数 (デフォルト: 10, 最大: 50)' },
        tab: { type: 'string', description: 'Top / Latest / Photos / Videos (デフォルト: Latest)' },
        full_text: { type: 'boolean', description: '長文の省略を避ける' },
        compact: { type: 'boolean', description: 'token 節約向けの compact 出力' },
        output_path: { type: 'string', description: '任意: output/x/ 配下に保存する相対パス' },
      },
      required: ['query'],
    },
  },
};

const XUSERPOSTS_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'XUserPosts',
    description: 'X / Twitter の指定ユーザーの投稿一覧を取得する。詳細は ReadToolDoc({ name: "XUserPosts" })。',
    parameters: {
      type: 'object',
      properties: {
        username: { type: 'string', description: 'X ユーザー名 (screen name)' },
        limit: { type: 'number', description: '件数 (デフォルト: 20, 最大: 50)' },
        full_text: { type: 'boolean', description: '長文の省略を避ける' },
        compact: { type: 'boolean', description: 'token 節約向けの compact 出力' },
        output_path: { type: 'string', description: '任意: output/x/ 配下に保存する相対パス' },
      },
      required: ['username'],
    },
  },
};

const XPOSTDETAIL_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'XPostDetail',
    description: 'twitter-cli を使って投稿 URL または tweet ID から詳細と reply を取得する。read-only。',
    parameters: {
      type: 'object',
      properties: {
        tweet: { type: 'string', description: 'tweet ID または https://x.com/.../status/... URL' },
        full_text: { type: 'boolean', description: '長文の省略を避ける' },
        compact: { type: 'boolean', description: 'token 節約向けの compact 出力' },
        output_path: { type: 'string', description: '任意: output/x/ 配下に保存する相対パス' },
      },
      required: ['tweet'],
    },
  },
};

const XFETCHCARDMEDIA_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'XFetchCardMedia',
    description: 'quiz / poll / link card 投稿の card 画像を取得する。XPostDetail が media:[] を返した特殊投稿でのみ呼ぶ (Playwright 起動で約 14 秒コスト)。詳細は ReadToolDoc({ name: "XFetchCardMedia" })。',
    parameters: {
      type: 'object',
      properties: {
        tweet: { type: 'string', description: 'tweet ID または https://x.com/{user}/status/{id} URL' },
        screen_name: { type: 'string', description: '任意: tweet ID だけ渡す場合に著者の screen_name を指定 (未指定なら "i" にフォールバック)' },
      },
      required: ['tweet'],
    },
  },
};

const XTIMELINE_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'XTimeline',
    description: 'ログイン中アカウントのホームタイムライン（For You / フォロー中）を取得する（twitter-cli 経由、認証 Cookie 設定が必要）。詳細は ReadToolDoc({ name: "XTimeline" })。',
    parameters: {
      type: 'object',
      properties: {
        tab: { type: 'string', enum: ['for_you', 'following'], description: 'for_you (デフォルト) / following' },
        limit: { type: 'number', description: '件数 (デフォルト: 20, 最大: 50)' },
        full_text: { type: 'boolean', description: '長文の省略を避ける' },
        compact: { type: 'boolean', description: 'token 節約向けの compact 出力' },
        output_path: { type: 'string', description: '任意: output/x/ 配下に保存する相対パス' },
      },
    },
  },
};

export const TOOL_DEFS: Record<string, ToolDef> = {
  XSearch: XSEARCH_DEF,
  XUserPosts: XUSERPOSTS_DEF,
  XPostDetail: XPOSTDETAIL_DEF,
  XFetchCardMedia: XFETCHCARDMEDIA_DEF,
  XTimeline: XTIMELINE_DEF,
};

type XHistoryRecord = {
  timestamp: string;
  tool: keyof typeof TOOL_DEFS;
  args: string[];
  status: 'success' | 'error';
  exitCode: number | null;
  outputPath?: string;
  error?: string;
};

/**
 * twitter-cli の YAML 出力に含まれる media[] の URL を fetch して
 * `{workspace}/logs/x-media/{tweetId}/{N}.{ext}` に保存し、エントリに
 * `localPath` を埋め込む。media[] が空のときは BrowseWeb fallback で
 * X.com の DOM から画像 URL を抽出して同じ DL ルートに乗せる (card / quiz 形式
 * の投稿対応)。
 *
 * 引数の `parsed` (YAML.parse 後のオブジェクト) を in-place mutate する。
 * 既存の outputPath 書き込みや LLM への返却は呼び出し側が再 stringify する。
 */
async function downloadTweetMedia(parsed: unknown, ctx: ToolContext): Promise<void> {
  const cfg = ctx.toolsConfig;
  if (cfg?.xDownloadMedia === 'never') return;

  const root = parsed as { data?: unknown };
  if (!root || typeof root !== 'object') return;
  const data = root.data;
  if (!Array.isArray(data)) return;

  for (const tweet of data) {
    if (!tweet || typeof tweet !== 'object') continue;
    const t = tweet as Record<string, unknown>;
    const tweetId = t.id ? String(t.id) : '';
    if (!tweetId) continue;

    const media = Array.isArray(t.media) ? (t.media as Array<Record<string, unknown>>) : [];
    // twitter-cli の `media: []` は信用する。以前は「card / quiz 投稿で画像が
    // pbs.twimg.com/card_img/... に隠れているかも」と Playwright を立ち上げて
    // X.com を毎ツイート開きに行っていたが、XSearch が 20 件返すと
    // 14s × 20 ≒ 5 分のハングを引き起こす上に、ヒット率は極めて低かった
    // (実機ログ: extracted 0 media URL(s) (graphql=0 dom=0))。
    // 画像が無いツイートでも text とメタデータは健全に返るので、付随情報
    // なしで OK とする。card_img をどうしても拾いたければ別 tool を切る。

    for (let i = 0; i < media.length; i++) {
      const m = media[i];
      if (!m || typeof m !== 'object') continue;
      const itemStartedAt = Date.now();
      try {
        const downloaded = await downloadMediaItem(m, tweetId, i, ctx);
        const itemDurationMs = Date.now() - itemStartedAt;
        if (downloaded) {
          m.localPath = downloaded.localPath;
          if (downloaded.bytes !== undefined) m.bytes = downloaded.bytes;
          logger.info(`[x-tools] media ${tweetId}[${i}] ${downloaded.bytes ?? '?'}B in ${itemDurationMs}ms`);
        } else if (itemDurationMs > 500) {
          // skipped (too large / no url) but the fetch itself took time
          logger.info(`[x-tools] media ${tweetId}[${i}] skipped after ${itemDurationMs}ms`);
        }
      } catch (err) {
        const itemDurationMs = Date.now() - itemStartedAt;
        logger.warn(`[x-tools] media DL failed for ${tweetId}[${i}] after ${itemDurationMs}ms: ${(err as Error).message}`);
      }
    }
  }
}

/** 動画 / 画像 / GIF の URL を選ぶ。動画は config モードに応じて poster (thumbnail) か variants 最高 bitrate (full) */
function pickMediaUrl(
  media: Record<string, unknown>,
  type: string,
  cfg: ToolContext['toolsConfig'],
): string | null {
  if (type === 'photo') {
    return typeof media.url === 'string' ? media.url : null;
  }

  const videoMode = cfg?.xDownloadVideo ?? 'thumbnail';
  if (videoMode === 'never') return null;

  if (videoMode === 'thumbnail') {
    // poster 画像 (`url` フィールドは twitter-cli 上で動画でも poster jpg を返す)
    return typeof media.url === 'string' ? media.url : null;
  }

  // full モード: variants から最高 bitrate の mp4 を取る
  if (type === 'video' || type === 'animated_gif') {
    const variants = media.variants;
    if (Array.isArray(variants)) {
      const mp4 = (variants as Array<Record<string, unknown>>)
        .filter((v) => {
          const ct = String(v.contentType ?? v.content_type ?? '');
          return ct.includes('mp4') || (typeof v.url === 'string' && v.url.endsWith('.mp4'));
        })
        .sort((a, b) => Number(b.bitrate ?? 0) - Number(a.bitrate ?? 0));
      const top = mp4[0];
      if (top && typeof top.url === 'string') return top.url;
    }
    if (typeof media.videoUrl === 'string') return media.videoUrl;
    if (typeof media.video_url === 'string') return media.video_url;
  }

  return typeof media.url === 'string' ? media.url : null;
}

/** URL から保存ファイル拡張子を推定する。クエリの format= も尊重 */
export function inferMediaExtension(rawUrl: string): string {
  try {
    const u = new URL(rawUrl);
    const fmt = u.searchParams.get('format');
    if (fmt && /^[a-z0-9]+$/i.test(fmt)) return '.' + fmt.toLowerCase();
    const m = u.pathname.match(/\.([a-z0-9]+)$/i);
    if (m) return '.' + m[1]!.toLowerCase();
  } catch {
    // ignore
  }
  return '.bin';
}

async function downloadMediaItem(
  media: Record<string, unknown>,
  tweetId: string,
  index: number,
  ctx: ToolContext,
): Promise<{ localPath: string; bytes?: number } | null> {
  const type = String(media.type ?? 'photo');
  const url = pickMediaUrl(media, type, ctx.toolsConfig);
  if (!url) return null;

  const maxBytes = (ctx.toolsConfig?.xMediaMaxMb ?? 25) * 1024 * 1024;
  const ext = inferMediaExtension(url);
  // tweetId はサニタイズ済み (twitter-cli が返す数値 ID) だが念のため
  const safeId = tweetId.replace(/[^A-Za-z0-9_-]/g, '_');
  const dir = path.join(ctx.workspacePath, 'logs', 'x-media', safeId);
  const filename = `${index}${ext}`;
  const fullPath = path.join(dir, filename);
  const relPath = `logs/x-media/${safeId}/${filename}`;

  // 既存ファイル: idempotent (同じ tweet を二度叩いても再 DL しない)
  if (fs.existsSync(fullPath)) {
    const stat = fs.statSync(fullPath);
    return { localPath: relPath, bytes: stat.size };
  }

  // CDN が slowloris-的に応答を停止するケースで個別 fetch が無限に hang
  // しないよう、合計 (接続 + 本体ダウンロード) で hard timeout を入れる。
  // 一枚の photo を 15 秒で取り切れないなら諦めて空 returns → 上位は warn ログだけ残して続行する。
  const timeoutMs = (ctx.toolsConfig?.xMediaFetchTimeoutSeconds ?? 15) * 1000;
  const signal = AbortSignal.timeout(timeoutMs);
  let response: Response;
  try {
    response = await fetch(url, { redirect: 'follow', signal });
  } catch (err) {
    const isTimeout = (err as { name?: string }).name === 'TimeoutError' || /aborted/i.test((err as Error).message);
    logger.warn(`[x-tools] media fetch ${url} ${isTimeout ? `timed out after ${timeoutMs}ms` : `failed: ${(err as Error).message}`}`);
    return null;
  }
  if (!response.ok) {
    logger.warn(`[x-tools] media fetch ${url} returned ${response.status}`);
    return null;
  }
  const contentLength = Number(response.headers.get('content-length') ?? 0);
  if (contentLength > 0 && contentLength > maxBytes) {
    logger.warn(`[x-tools] media ${url} skipped: content-length ${contentLength} > cap ${maxBytes}`);
    return null;
  }

  let buf: Buffer;
  try {
    buf = Buffer.from(await response.arrayBuffer());
  } catch (err) {
    const isTimeout = (err as { name?: string }).name === 'TimeoutError' || /aborted/i.test((err as Error).message);
    logger.warn(`[x-tools] media body ${url} ${isTimeout ? `timed out after ${timeoutMs}ms` : `failed: ${(err as Error).message}`}`);
    return null;
  }
  if (buf.byteLength > maxBytes) {
    logger.warn(`[x-tools] media ${url} skipped: ${buf.byteLength} > cap ${maxBytes}`);
    return null;
  }

  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(fullPath, buf);
  return { localPath: relPath, bytes: buf.byteLength };
}

function appendXHistory(ctx: ToolContext, record: XHistoryRecord): void {
  try {
    const logsDir = path.join(ctx.workspacePath, 'logs');
    fs.mkdirSync(logsDir, { recursive: true });
    fs.appendFileSync(path.join(logsDir, 'x-cli-history.jsonl'), `${JSON.stringify(record)}\n`, 'utf-8');
  } catch (err) {
    logger.warn(`[x-tools] failed to write history: ${(err as Error).message}`);
  }
}

function normalizeCommand(configured: string[] | string | undefined): string[] {
  if (Array.isArray(configured)) {
    const filtered = configured.map((entry) => String(entry).trim()).filter(Boolean);
    return filtered.length > 0 ? filtered : ['twitter'];
  }
  if (typeof configured === 'string' && configured.trim()) {
    return configured.trim().split(/\s+/);
  }
  return ['twitter'];
}

function maybePushFlag(args: string[], enabled: unknown, flag: string): void {
  if (enabled) args.push(flag);
}

function clampLimit(raw: unknown, fallback: number): number {
  const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : fallback;
  return Math.min(Math.max(1, Math.trunc(value)), 50);
}

function filterStderrWarnings(stderr: string): { warnings: string; errors: string } {
  const lines = stderr.split('\n');
  const warnings: string[] = [];
  const errors: string[] = [];
  for (const line of lines) {
    if (/^\s*WARNING\b/i.test(line) || /^\s*$/.test(line)) {
      warnings.push(line);
    } else {
      errors.push(line);
    }
  }
  return { warnings: warnings.join('\n').trim(), errors: errors.join('\n').trim() };
}

function resolveOptionalOutputPath(ctx: ToolContext, requestedPath: unknown): string | null {
  if (typeof requestedPath !== 'string' || !requestedPath.trim()) return null;
  if (!ctx.editAllowed) {
    throw new Error('output_path requires an edit-enabled movement');
  }
  return resolveOutputPathWithin(ctx.workspacePath, requestedPath, ['output/x']);
}

async function runTwitterCli(
  toolName: keyof typeof TOOL_DEFS,
  args: string[],
  ctx: ToolContext,
  outputPath: string | null,
): Promise<ToolResult> {
  const command = normalizeCommand(ctx.toolsConfig?.xCliCommand);
  await checkTwitterCliVersion(command);
  const timeoutSeconds = ctx.toolsConfig?.xTimeout ?? DEFAULT_X_TIMEOUT_SECONDS;
  const env = {
    ...process.env,
    ...(ctx.toolsConfig?.xAuthToken ? { TWITTER_AUTH_TOKEN: ctx.toolsConfig.xAuthToken } : {}),
    ...(ctx.toolsConfig?.xCt0 ? { TWITTER_CT0: ctx.toolsConfig.xCt0 } : {}),
    ...(ctx.toolsConfig?.xProxy ? { TWITTER_PROXY: ctx.toolsConfig.xProxy } : {}),
    ...(ctx.toolsConfig?.xChromeProfile ? { TWITTER_CHROME_PROFILE: ctx.toolsConfig.xChromeProfile } : {}),
  };

  const fullArgs = [...command.slice(1), ...args];
  logger.debug(`[x-tools] executing ${command[0]} ${fullArgs.join(' ')}`);
  const cliStartedAt = Date.now();

  const result = await new Promise<{ stdout: string; stderr: string; exitCode: number | null; spawnError?: Error }>((resolve) => {
    const child = childProcess.spawn(command[0]!, fullArgs, {
      cwd: ctx.workspacePath,
      env,
      stdio: ['ignore', 'pipe', 'pipe'],
    });

    let stdout = '';
    let stderr = '';
    let settled = false;

    const finish = (payload: { stdout: string; stderr: string; exitCode: number | null; spawnError?: Error }) => {
      if (settled) return;
      settled = true;
      resolve(payload);
    };

    const timer = setTimeout(() => {
      child.kill('SIGKILL');
      finish({
        stdout,
        stderr: stderr || `twitter-cli timed out after ${timeoutSeconds}s`,
        exitCode: null,
        spawnError: new Error(`twitter-cli timed out after ${timeoutSeconds}s`),
      });
    }, timeoutSeconds * 1000);

    child.stdout.on('data', (chunk: Buffer | string) => {
      stdout += chunk.toString();
    });
    child.stderr.on('data', (chunk: Buffer | string) => {
      stderr += chunk.toString();
    });
    child.on('error', (err) => {
      clearTimeout(timer);
      finish({ stdout, stderr, exitCode: null, spawnError: err });
    });
    child.on('close', (code) => {
      clearTimeout(timer);
      finish({ stdout, stderr, exitCode: code });
    });
  });

  if (result.spawnError) {
    const errorMessage = /ENOENT/.test(result.spawnError.message)
      ? `twitter-cli command "${command[0]}" was not found. Install twitter-cli or set tools.x_cli_command in config.yaml.`
      : result.stderr.trim() || result.spawnError.message;
    appendXHistory(ctx, {
      timestamp: new Date().toISOString(),
      tool: toolName,
      args,
      status: 'error',
      exitCode: result.exitCode,
      error: errorMessage,
    });
    return { output: `X tool error: ${errorMessage}`, isError: true };
  }

  if (result.exitCode !== 0) {
    const { warnings, errors } = filterStderrWarnings(result.stderr);
    if (warnings) {
      logger.debug(`[x-tools] stderr warnings: ${warnings}`);
    }
    const errorMessage = errors || `twitter-cli exited with code ${result.exitCode}`;
    appendXHistory(ctx, {
      timestamp: new Date().toISOString(),
      tool: toolName,
      args,
      status: 'error',
      exitCode: result.exitCode,
      error: errorMessage,
    });
    return { output: `X tool error: ${errorMessage}`, isError: true };
  }

  // Log stderr warnings on success (exit code 0)
  if (result.stderr.trim()) {
    const { warnings } = filterStderrWarnings(result.stderr);
    if (warnings) {
      logger.debug(`[x-tools] stderr warnings: ${warnings}`);
    }
  }

  const rawStdout = result.stdout.trim();
  if (!rawStdout) {
    appendXHistory(ctx, {
      timestamp: new Date().toISOString(),
      tool: toolName,
      args,
      status: 'error',
      exitCode: result.exitCode,
      error: 'twitter-cli returned empty output',
    });
    return { output: 'X tool error: twitter-cli returned empty output', isError: true };
  }

  const cliDurationMs = Date.now() - cliStartedAt;
  logger.info(`[x-tools] ${toolName}: twitter-cli took ${cliDurationMs}ms (exit=${result.exitCode}, stdout=${rawStdout.length}B)`);

  // YAML を parse → media[] を fetch / fallback で埋めて localPath を追記 → 再 stringify。
  // YAML.parse が失敗したり、出力が想定外の形 (--compact 利用時など) のときは raw のまま使う。
  let stdout = rawStdout;
  const mediaStartedAt = Date.now();
  try {
    const parsed: unknown = YAML.parse(rawStdout);
    if (parsed && typeof parsed === 'object') {
      await downloadTweetMedia(parsed, ctx);
      stdout = YAML.stringify(parsed).trimEnd();
    }
  } catch (err) {
    logger.warn(`[x-tools] media post-process skipped (YAML parse failed): ${(err as Error).message}`);
  }
  const mediaDurationMs = Date.now() - mediaStartedAt;
  if (mediaDurationMs > 100) {
    // Only log when post-process actually took time. <100ms is noise (no media,
    // YAML parse fail). >100ms is what we care about for hang diagnosis.
    logger.info(`[x-tools] ${toolName}: media post-process took ${mediaDurationMs}ms`);
  }

  if (outputPath) {
    fs.mkdirSync(path.dirname(outputPath), { recursive: true });
    fs.writeFileSync(outputPath, stdout, 'utf-8');
  }

  appendXHistory(ctx, {
    timestamp: new Date().toISOString(),
    tool: toolName,
    args,
    status: 'success',
    exitCode: result.exitCode,
    outputPath: outputPath ? path.relative(ctx.workspacePath, outputPath).split(path.sep).join('/') : undefined,
  });

  const suffix = outputPath ? `\n\nSaved to ${path.relative(ctx.workspacePath, outputPath).split(path.sep).join('/')}` : '';
  return { output: `${stdout}${suffix}`, isError: false };
}

function parseXPostsFromYaml(yamlText: string): XPostItem[] {
  try {
    const parsed = YAML.parse(yamlText);
    if (!parsed?.data || !Array.isArray(parsed.data)) return [];
    return parsed.data
      .filter((item: Record<string, unknown>) => item.id && item.text && !item.isRetweet)
      .map((item: Record<string, unknown>): XPostItem => {
        const author = item.author as Record<string, unknown> | undefined;
        const metrics = item.metrics as Record<string, unknown> | undefined;
        const screenName = String(author?.screenName ?? '');
        return {
          id: String(item.id),
          text: String(item.text),
          authorName: String(author?.name ?? ''),
          authorScreenName: screenName,
          authorImageUrl: String(author?.profileImageUrl ?? ''),
          likes: Number(metrics?.likes ?? 0),
          retweets: Number(metrics?.retweets ?? 0),
          replies: Number(metrics?.replies ?? 0),
          views: Number(metrics?.views ?? 0),
          createdAt: String(item.createdAtISO ?? ''),
          postUrl: `https://x.com/${screenName}/status/${item.id}`,
        };
      });
  } catch (err) {
    logger.warn(`[x-tools] YAML parse failed for structured blocks: ${err}`);
    return [];
  }
}

async function executeXSearch(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
  const query = String(input['query'] ?? '').trim();
  if (!query) return { output: 'XSearch error: query is required', isError: true };

  const args = ['search', query, '-t', String(input['tab'] ?? 'Latest'), '--max', String(clampLimit(input['limit'], 10)), '--yaml'];
  maybePushFlag(args, input['full_text'], '--full-text');
  maybePushFlag(args, input['compact'], '--compact');
  let outputPath: string | null;
  try {
    outputPath = resolveOptionalOutputPath(ctx, input['output_path']);
  } catch (err) {
    return { output: `XSearch error: ${(err as Error).message}`, isError: true };
  }
  const result = await runTwitterCli('XSearch', args, ctx, outputPath);
  if (result.isError) return result;

  // 構造化データを生成
  const posts = parseXPostsFromYaml(result.output);
  if (posts.length > 0) {
    const refId = `xposts-${Date.now()}`;
    const structuredBlocks: StructuredBlock[] = [{
      refId,
      type: 'x_posts',
      title: `X 検索結果: 「${query}」`,
      data: { query, posts },
    }];
    return { output: `${result.output}\n\n[[embed:${refId}]]`, isError: false, structuredBlocks };
  }
  return result;
}

async function executeXUserPosts(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
  const username = String(input['username'] ?? '').trim();
  if (!username) return { output: 'XUserPosts error: username is required', isError: true };

  const args = ['user-posts', username, '--max', String(clampLimit(input['limit'], 20)), '--yaml'];
  maybePushFlag(args, input['full_text'], '--full-text');
  maybePushFlag(args, input['compact'], '--compact');
  let outputPath: string | null;
  try {
    outputPath = resolveOptionalOutputPath(ctx, input['output_path']);
  } catch (err) {
    return { output: `XUserPosts error: ${(err as Error).message}`, isError: true };
  }
  return runTwitterCli('XUserPosts', args, ctx, outputPath);
}

async function executeXTimeline(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
  const tab = String(input['tab'] ?? 'for_you');
  const following = tab === 'following';
  const args = ['feed', '--max', String(clampLimit(input['limit'], 20)), '--yaml'];
  if (following) args.push('-t', 'following');
  maybePushFlag(args, input['full_text'], '--full-text');
  maybePushFlag(args, input['compact'], '--compact');
  let outputPath: string | null;
  try {
    outputPath = resolveOptionalOutputPath(ctx, input['output_path']);
  } catch (err) {
    return { output: `XTimeline error: ${(err as Error).message}`, isError: true };
  }
  const result = await runTwitterCli('XTimeline', args, ctx, outputPath);
  if (result.isError) return result;

  const posts = parseXPostsFromYaml(result.output);
  if (posts.length > 0) {
    const refId = `xposts-${Date.now()}`;
    const structuredBlocks: StructuredBlock[] = [{
      refId,
      type: 'x_posts',
      title: following ? 'X タイムライン（フォロー中）' : 'X タイムライン（For You）',
      data: { query: following ? 'following' : 'for_you', posts },
    }];
    return { output: `${result.output}\n\n[[embed:${refId}]]`, isError: false, structuredBlocks };
  }
  return result;
}

async function executeXPostDetail(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
  const tweet = String(input['tweet'] ?? '').trim();
  if (!tweet) return { output: 'XPostDetail error: tweet is required', isError: true };

  const args = ['tweet', tweet, '--yaml'];
  maybePushFlag(args, input['full_text'], '--full-text');
  maybePushFlag(args, input['compact'], '--compact');
  let outputPath: string | null;
  try {
    outputPath = resolveOptionalOutputPath(ctx, input['output_path']);
  } catch (err) {
    return { output: `XPostDetail error: ${(err as Error).message}`, isError: true };
  }
  return runTwitterCli('XPostDetail', args, ctx, outputPath);
}

/** pbs.twimg.com の URL を large サイズに正規化する。media と card_img 両方対応。 */
function upgradePbsUrl(rawUrl: string): string {
  try {
    const u = new URL(rawUrl);
    if (u.hostname === 'pbs.twimg.com' && (u.pathname.startsWith('/media/') || u.pathname.startsWith('/card_img/'))) {
      u.searchParams.set('name', 'large');
      return u.toString();
    }
  } catch {
    // ignore
  }
  return rawUrl;
}

/**
 * X.com の Web ページを Playwright で開き、quiz / poll / link card 投稿に
 * 紐づく card_img URL を抽出する。
 *
 * 旧 `fetchMediaFromWebPage` を `XFetchCardMedia` 専用に復活させたもの。
 * 違いは「LLM の明示呼び出しでのみ発動」する点。`downloadTweetMedia` 内では
 * 呼ばないので XSearch / XUserPosts で全件 Playwright が走ることはない。
 *
 * 2 経路で URL を拾う:
 *   1. GraphQL response intercept: X 内部 API の card.legacy.binding_values[].
 *      value.image_value.url に card 画像 URL が入っているので、正規表現で吸収
 *   2. DOM scope 抽出: target tweet の <article> 内 <img src> をフィルタ
 *
 * 関連 tweet 由来の画像を巻き込まないよう、必ず article scope (target status を
 * 含む article) に絞ってから DOM 抽出する。
 */
async function fetchCardMediaFromWebPage(
  tweetId: string,
  screenName: string,
  ctx: ToolContext,
): Promise<Array<{ url: string }>> {
  const url = `https://x.com/${screenName}/status/${tweetId}`;

  const browserMod = await import('./browser.js') as typeof import('./browser.js');
  const browser = await browserMod.getCaptchaPoolBrowser();
  const browserContext = await browser.newContext({
    userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
    locale: 'ja-JP',
  });

  // ログイン Cookie を inject。未ログインだと X は tweet 本文を出さず login wall
  // を表示するため、card_img も DOM に乗らない。
  const cookies: Array<{
    name: string; value: string; domain: string; path: string;
    secure: boolean; httpOnly?: boolean; sameSite?: 'Lax' | 'Strict' | 'None';
  }> = [];
  const authToken = ctx.toolsConfig?.xAuthToken;
  const ct0 = ctx.toolsConfig?.xCt0;
  if (authToken) {
    cookies.push({ name: 'auth_token', value: authToken, domain: '.x.com', path: '/', secure: true, httpOnly: true, sameSite: 'None' });
    cookies.push({ name: 'auth_token', value: authToken, domain: '.twitter.com', path: '/', secure: true, httpOnly: true, sameSite: 'None' });
  }
  if (ct0) {
    cookies.push({ name: 'ct0', value: ct0, domain: '.x.com', path: '/', secure: true, sameSite: 'Lax' });
    cookies.push({ name: 'ct0', value: ct0, domain: '.twitter.com', path: '/', secure: true, sameSite: 'Lax' });
  }
  if (cookies.length > 0) {
    await browserContext.addCookies(cookies);
  }

  const page = await browserContext.newPage();

  const captured = new Set<string>();
  const responseListener = (resp: import('playwright').Response): void => {
    const respUrl = resp.url();
    if (!/graphql/.test(respUrl)) return;
    resp.text().then((text) => {
      const matches = text.match(/https:\/\/pbs\.twimg\.com\/(?:media|card_img)\/[A-Za-z0-9_/-]+(?:\?[^"\s\\]*)?/g);
      matches?.forEach((m) => captured.add(m));
    }).catch(() => { /* ignore */ });
  };
  page.on('response', responseListener);

  try {
    page.setDefaultTimeout(30_000);
    await page.goto(url, { waitUntil: 'domcontentloaded' });
    try {
      await page.waitForSelector(
        'img[src*="pbs.twimg.com/media/"], img[src*="pbs.twimg.com/card_img/"], a[href*="/photo/"]',
        { timeout: 8_000 },
      );
    } catch {
      // 画像なし投稿 — 結果 0 件で問題ない
    }
    await page.waitForLoadState('networkidle', { timeout: 4_000 }).catch(() => {});
    await page.waitForTimeout(1500);

    // 関連ツイートの画像を巻き込まないよう、target status の article 内に絞る
    const domUrls = await page.evaluate(`
      (function(tid) {
        var articles = Array.from(document.querySelectorAll('article'));
        var target = articles.find(function(a) { return a.querySelector('a[href*="/status/' + tid + '"]'); });
        if (!target) return [];
        return Array.from(target.querySelectorAll('img'))
          .map(function(i) { return i.src; })
          .filter(function(s) { return /pbs\\.twimg\\.com\\/(media|card_img)\\//.test(s); });
      })(${JSON.stringify(tweetId)})
    `) as string[];

    // GraphQL + DOM をマージ。pathname でユニーク化 (?name=small/large の重複を畳む)
    const seen = new Set<string>();
    const out: Array<{ url: string }> = [];
    for (const raw of [...captured, ...domUrls]) {
      const upgraded = upgradePbsUrl(raw);
      const key = (() => { try { return new URL(upgraded).pathname; } catch { return upgraded; } })();
      if (seen.has(key)) continue;
      seen.add(key);
      out.push({ url: upgraded });
    }

    logger.info(`[x-tools] XFetchCardMedia ${tweetId}: extracted ${out.length} URL(s) (graphql=${captured.size} dom=${domUrls.length})`);
    return out;
  } finally {
    page.off('response', responseListener);
    // browserContext.close が稀にハングするので 5 秒で打ち切る
    await Promise.race([
      page.close(),
      new Promise((r) => setTimeout(r, 5_000)),
    ]).catch(() => {});
    await Promise.race([
      browserContext.close(),
      new Promise((r) => setTimeout(r, 5_000)),
    ]).catch(() => {});
  }
}

/**
 * Parse a tweet input that may be either a raw ID, a status URL, or a
 * twitter-cli `https://twitter.com/...` URL. Returns the numeric ID + the
 * screen_name if extractable from a URL (else null).
 */
function parseTweetRef(raw: string): { tweetId: string; screenName: string | null } | null {
  const trimmed = raw.trim();
  if (!trimmed) return null;
  // Bare numeric ID
  if (/^\d+$/.test(trimmed)) return { tweetId: trimmed, screenName: null };
  // URL form
  const m = trimmed.match(/(?:x\.com|twitter\.com)\/([^/]+)\/status(?:es)?\/(\d+)/);
  if (m) return { tweetId: m[2]!, screenName: m[1]! };
  return null;
}

async function executeXFetchCardMedia(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
  const rawTweet = String(input['tweet'] ?? '').trim();
  if (!rawTweet) return { output: 'XFetchCardMedia error: tweet is required', isError: true };
  const parsed = parseTweetRef(rawTweet);
  if (!parsed) {
    return { output: 'XFetchCardMedia error: could not parse tweet ID from input', isError: true };
  }
  const explicitScreenName = String(input['screen_name'] ?? '').trim();
  // URL から抽出した screen_name > 引数指定 > 'i' (X の anonymous status path)。
  // X.com は /i/status/{id} でも tweet が表示されるので screen_name 未知でも動く。
  const screenName = parsed.screenName ?? (explicitScreenName || 'i');
  const tweetId = parsed.tweetId;

  const fetchStartedAt = Date.now();
  let candidates: Array<{ url: string }>;
  try {
    candidates = await fetchCardMediaFromWebPage(tweetId, screenName, ctx);
  } catch (err) {
    const dur = Date.now() - fetchStartedAt;
    const message = (err as Error).message;
    logger.warn(`[x-tools] XFetchCardMedia ${tweetId} failed after ${dur}ms: ${message}`);
    appendXHistory(ctx, {
      timestamp: new Date().toISOString(),
      tool: 'XFetchCardMedia',
      args: [tweetId],
      status: 'error',
      exitCode: null,
      error: message,
    });
    return { output: `XFetchCardMedia error: ${message}`, isError: true };
  }
  const fetchDurationMs = Date.now() - fetchStartedAt;

  if (candidates.length === 0) {
    appendXHistory(ctx, {
      timestamp: new Date().toISOString(),
      tool: 'XFetchCardMedia',
      args: [tweetId],
      status: 'success',
      exitCode: 0,
    });
    return {
      output: `XFetchCardMedia: no card media found for tweet ${tweetId} (graphql=0 dom=0, ${fetchDurationMs}ms). Tweet may be plain text or login may have failed.`,
      isError: false,
    };
  }

  // 拾った URL を個別 DL。downloadMediaItem は AbortSignal.timeout 込みなので
  // 単発 fetch が止まっても 15 秒で諦める。
  const saved: string[] = [];
  for (let i = 0; i < candidates.length; i++) {
    const media: Record<string, unknown> = { type: 'photo', url: candidates[i]!.url, source: 'browser' };
    try {
      const downloaded = await downloadMediaItem(media, tweetId, i, ctx);
      if (downloaded) saved.push(downloaded.localPath);
    } catch (err) {
      logger.warn(`[x-tools] XFetchCardMedia ${tweetId}[${i}] DL failed: ${(err as Error).message}`);
    }
  }

  appendXHistory(ctx, {
    timestamp: new Date().toISOString(),
    tool: 'XFetchCardMedia',
    args: [tweetId],
    status: 'success',
    exitCode: 0,
  });

  if (saved.length === 0) {
    return {
      output: `XFetchCardMedia: extracted ${candidates.length} URL(s) but none could be downloaded (${fetchDurationMs}ms). See logs for details.`,
      isError: false,
    };
  }

  const lines = [
    `XFetchCardMedia: saved ${saved.length} image(s) for tweet ${tweetId} (${fetchDurationMs}ms):`,
    ...saved.map((p) => `  - ${p}`),
  ];
  return { output: lines.join('\n'), isError: false };
}

export async function executeTool(
  name: string,
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult | null> {
  switch (name) {
    case 'XSearch':
      return executeXSearch(input, ctx);
    case 'XUserPosts':
      return executeXUserPosts(input, ctx);
    case 'XTimeline':
      return executeXTimeline(input, ctx);
    case 'XPostDetail':
      return executeXPostDetail(input, ctx);
    case 'XFetchCardMedia':
      return executeXFetchCardMedia(input, ctx);
    default:
      return null;
  }
}
