import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { resolveOutputPathWithin } from './core.js';
import { logger } from '../../logger.js';
import type { SearchFilterConfig } from '../../config.js';
import { checkSSRF, ssrfSafeFetch } from './shared/ssrf.js';
import { htmlToText } from './shared/html.js';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import {
  looksLikeBinaryBytes,
  decodeText,
  SNIFF_HEAD_BYTES,
  type BinaryVerdict,
} from './binary-detect.js';

const BINARY_CONTENT_TYPE_PREFIXES = [
  'application/pdf',
  'application/octet-stream',
  'application/zip',
  'application/x-zip-compressed',
  'application/vnd.',
  'image/',
  'audio/',
  'video/',
];

// WebFetch text body は最大 5MB で打ち切る（巨大 HTML による context 膨張防止）
const MAX_WEBFETCH_BODY_BYTES = 5 * 1024 * 1024;

// --- ツール定義 ---

const WEBSEARCH_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'WebSearch',
    description: 'Google 検索でインターネットを検索する。検索クエリにプライベートIP、内部ドメイン、メールアドレス、電話番号等の機密情報を含めないでください。詳細は ReadToolDoc({ name: "WebSearch" })。',
    parameters: {
      type: 'object',
      properties: {
        query: { type: 'string', description: '検索クエリ' },
        limit: { type: 'number', description: '結果件数 (デフォルト: 5, 最大: 20)' },
      },
      required: ['query'],
    },
  },
};

const WEBFETCH_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'WebFetch',
    description: 'URL からページのテキスト内容を取得する（静的ページ向け、軽量・高速）。動的レンダリングや操作が必要なら BrowseWeb。詳細は ReadToolDoc({ name: "WebFetch" })。',
    parameters: {
      type: 'object',
      properties: {
        url: { type: 'string', description: '取得する URL' },
        selector: { type: 'string', description: 'CSS セレクタで特定要素だけ取得 (省略時はページ全体)' },
      },
      required: ['url'],
    },
  },
};

const DOWNLOADFILE_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'DownloadFile',
    description: 'URL からファイル（PDF・画像・バイナリ等）をダウンロードし、workspace の input/ または output/ に保存する。WebFetch では取得できない PDF や画像ファイルに使う。詳細・命名規約は ReadToolDoc({ name: "DownloadFile" })。',
    parameters: {
      type: 'object',
      properties: {
        url: { type: 'string', description: 'ダウンロードする URL' },
        filename: { type: 'string', description: '保存するファイル名（省略時は URL から推定）' },
        section: { type: 'string', description: '保存先: "input" または "output" (デフォルト: "input")' },
      },
      required: ['url'],
    },
  },
};

// --- 検索クエリサニタイズ ---

// プライベート IPv4 アドレスにマッチ (10.*, 172.16-31.*, 192.168.*, 127.*)
const PRIVATE_IP_PATTERN = /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|127\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g;

// メールアドレス
const EMAIL_PATTERN = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;

// 日本の電話番号 (090-1234-5678, 03-1234-5678, 0120-123-456 等)
const PHONE_PATTERN = /\b0\d{1,4}[-‐ー]\d{1,4}[-‐ー]\d{1,4}\b/g;

// 内部ドメイン (.local, .internal, .lan, .intranet, .corp, .home)
const INTERNAL_DOMAIN_PATTERN = /\b[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.(?:local|internal|lan|intranet|corp|home)\b/g;

/**
 * 検索クエリから機密情報を除去する。
 * @returns サニタイズ済みクエリ。空になった場合は null を返す。
 */
export function sanitizeQuery(query: string, config: SearchFilterConfig): string | null {
  const autoBlock = {
    privateIp: config.autoBlock?.privateIp !== false,
    internalDomain: config.autoBlock?.internalDomain !== false,
    email: config.autoBlock?.email !== false,
    phone: config.autoBlock?.phone !== false,
  };

  let result = query;

  // 自動ブロックパターンの適用
  if (autoBlock.privateIp) {
    result = result.replace(PRIVATE_IP_PATTERN, '');
  }
  if (autoBlock.email) {
    result = result.replace(EMAIL_PATTERN, '');
  }
  if (autoBlock.phone) {
    result = result.replace(PHONE_PATTERN, '');
  }
  if (autoBlock.internalDomain) {
    result = result.replace(INTERNAL_DOMAIN_PATTERN, '');
  }

  // カスタムブロックパターンの適用
  if (config.blockedPatterns) {
    for (const pattern of config.blockedPatterns) {
      result = result.split(pattern).join('');
    }
  }

  // 空白の正規化
  result = result.replace(/\s+/g, ' ').trim();

  return result.length > 0 ? result : null;
}

/** Google 検索結果ページの innerText から URL + タイトル + スニペットを抽出する */
export function parseSearchResultsFromText(
  text: string,
  limit: number,
): Array<{ title: string; url: string; snippet: string }> {
  const lines = text.split('\n');
  const results: Array<{ title: string; url: string; snippet: string }> = [];
  const urlPattern = /^(https?:\/\/[^\s]+)$/;

  for (let i = 0; i < lines.length; i++) {
    const match = lines[i]!.trim().match(urlPattern);
    if (!match) continue;
    const url = match[1]!;

    // Google 内部リンクを除外
    if (url.includes('google.com/search') || url.includes('google.com/imgres')) continue;

    const title = (i > 0 ? lines[i - 1]!.trim() : '') || '(タイトルなし)';
    const snippet = (i + 1 < lines.length ? lines[i + 1]!.trim() : '') || '';

    results.push({ title, url, snippet });
    if (results.length >= limit) break;
  }

  return results;
}

function isLikelyBinaryResponse(contentType: string): boolean {
  const normalized = contentType.toLowerCase();
  if (!normalized) return false;
  if (normalized.startsWith('text/')) return false;
  if (normalized.includes('json') || normalized.includes('xml') || normalized.includes('javascript') || normalized.includes('svg')) return false;
  return BINARY_CONTENT_TYPE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}

function isPdfResponse(rawUrl: string, contentType: string): boolean {
  const normalized = contentType.toLowerCase();
  if (normalized.startsWith('application/pdf')) return true;
  try {
    return new URL(rawUrl).pathname.toLowerCase().endsWith('.pdf');
  } catch {
    return false;
  }
}

type WebFetchHistoryRecord = {
  timestamp: string;
  url: string;
  selector?: string;
  status: number | null;
  contentType: string | null;
  outcome: 'success' | 'invalid_url' | 'ssrf_blocked' | 'http_error' | 'pdf_blocked' | 'binary_blocked' | 'error';
  error?: string;
  screenshotPath?: string;
};

type DownloadHistoryRecord = {
  timestamp: string;
  url: string;
  savedPath: string | null;
  status: number | null;
  contentType: string | null;
  bytes: number | null;
  outcome: 'success' | 'invalid_url' | 'ssrf_blocked' | 'http_error' | 'error';
  error?: string;
};

type WebSearchHistoryRecord = {
  timestamp: string;
  query: string;
  limit: number;
  method: string;
  resultCount: number;
  outcome: 'success' | 'captcha' | 'timeout' | 'error';
  fallback: boolean;
  error?: string;
};

export function appendWebSearchHistory(ctx: ToolContext, record: WebSearchHistoryRecord): void {
  try {
    const logsDir = path.join(ctx.workspacePath, 'logs');
    const historyPath = path.join(logsDir, 'websearch-history.jsonl');
    fs.mkdirSync(logsDir, { recursive: true });
    fs.appendFileSync(historyPath, `${JSON.stringify(record)}\n`, 'utf-8');
  } catch (e) {
    logger.warn(`[WebSearch] failed to write history: ${(e as Error).message}`);
  }
}

const DEFAULT_USER_AGENT =
  'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';

export interface SearchResult {
  title: string;
  url: string;
  snippet: string;
}

/** SearXNG API 経由の検索（フォールバック用） */
export async function searchViaSearxng(
  query: string,
  limit: number,
  ctx: ToolContext,
): Promise<SearchResult[]> {
  const searxngUrl = ctx.toolsConfig?.searxngUrl ?? 'http://searxng:8080';
  const timeoutSec = ctx.toolsConfig?.websearchTimeout ?? 15;
  const url = `${searxngUrl}/search?q=${encodeURIComponent(query)}&format=json&language=ja`;

  logger.debug(`[WebSearch] SearXNG fallback: url=${url}`);

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutSec * 1000);

  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timer);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status} ${response.statusText}`);
    }

    const json = (await response.json()) as {
      results?: Array<{ title?: string; url?: string; content?: string }>;
    };

    return (json.results ?? []).slice(0, limit).map((r) => ({
      title: r.title ?? '(タイトルなし)',
      url: r.url ?? '',
      snippet: r.content ?? '',
    }));
  } catch (e) {
    clearTimeout(timer);
    throw e;
  }
}

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

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

/**
 * WebFetch 成功時に Playwright でファーストビューのスクショを撮り、
 * logs/webfetch-screenshots/ に保存して dataUrl を返す。
 * - vlmEnabled 無効時はスキップ
 * - Playwright 利用不可・ネットワーク失敗時はログ出力のみで null を返す（WebFetch 本体は成功として扱う）
 */
async function captureWebFetchScreenshot(
  ctx: ToolContext,
  url: string,
): Promise<{ dataUrl: string; savedPath: string } | null> {
  if (!ctx.vlmEnabled) return null;
  if (ctx.toolsConfig?.webfetchScreenshot === false) return null;

  const timeoutMs = ctx.toolsConfig?.webfetchScreenshotTimeoutMs ?? 15_000;

  let context: import('playwright').BrowserContext | null = null;
  let page: import('playwright').Page | null = null;
  try {
    // WebFetch の preview スクショは "タスク横断で共有してよい" 操作なので CAPTCHA Pool 側を借りる。
    // 個別タスクの BrowseWeb session は使わない (タスク隔離を保つ)。
    const { getCaptchaPoolBrowser } = await import('./browser.js');
    const browser = await getCaptchaPoolBrowser();
    context = await browser.newContext({
      viewport: { width: 1280, height: 1600 },
      userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    });
    page = await context.newPage();

    await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
    // ネットワークが落ち着くまで最大 3 秒だけ待つ（失敗しても続行）
    await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => undefined);

    const logsDir = path.join(ctx.workspacePath, 'logs', 'webfetch-screenshots');
    fs.mkdirSync(logsDir, { recursive: true });
    const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 10);
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const filename = `${timestamp}-${hash}.png`;
    const savedPath = path.join(logsDir, filename);

    const buffer = await page.screenshot({ fullPage: false, type: 'png' });
    fs.writeFileSync(savedPath, buffer);

    const dataUrl = `data:image/png;base64,${buffer.toString('base64')}`;
    logger.debug(`[WebFetch] screenshot saved: ${path.relative(ctx.workspacePath, savedPath)} (${buffer.byteLength} bytes)`);
    return { dataUrl, savedPath };
  } catch (e) {
    const msg = (e as Error).message ?? String(e);
    logger.warn(`[WebFetch] screenshot failed for ${url}: ${msg}`);
    return null;
  } finally {
    if (page) await page.close().catch(() => undefined);
    if (context) await context.close().catch(() => undefined);
  }
}

// --- WebSearch 実装 (ブラウザ検索チェーン + SearXNG フォールバック) ---

/** CAPTCHA / bot 検出パターン（日本語・英語両対応） */
const CAPTCHA_PATTERN = /unusual traffic|CAPTCHA|captcha|通常と異なるトラフィック|ロボットではない|not a robot|verify you are human|Drag the slider/i;

/** 検索エンジン定義 */
interface SearchEngineConfig {
  name: string;
  buildUrl: (query: string, limit: number) => string;
  /** DOM セレクタでの結果パーススクリプト（limit を埋め込む文字列テンプレート） */
  domScript: (limit: number) => string;
  /** 内部リンクを除外するパターン（URL に含まれていたらスキップ） */
  internalUrlPatterns?: string[];
}

const SEARCH_ENGINES: SearchEngineConfig[] = [
  {
    name: 'Google',
    buildUrl: (q, limit) => `https://www.google.com/search?q=${encodeURIComponent(q)}&hl=ja&num=${limit}`,
    domScript: (limit) => `
      (function(maxResults) {
        var items = [];
        var seen = {};
        // 2025-2026: Google は .g 以外に .MjjYud / .Gx5Zad / [data-sokoban-container] [jscontroller] も使う
        var els = document.querySelectorAll('div.MjjYud, div.g, div.Gx5Zad, [data-sokoban-container] [jscontroller]');
        for (var i = 0; i < els.length; i++) {
          if (items.length >= maxResults) break;
          var el = els[i];
          var titleEl = el.querySelector('h3');
          if (!titleEl) continue;
          // h3 を内包する <a> が本当の結果リンク。最初の <a> だと「このページについて」等を拾ってしまう
          var linkEl = titleEl.closest('a[href]');
          if (!linkEl) continue;
          var href = linkEl.getAttribute('href') || '';
          if (!/^https?:\\/\\//.test(href)) continue;
          if (href.indexOf('google.com/search') !== -1 || href.indexOf('google.com/imgres') !== -1) continue;
          if (seen[href]) continue;
          seen[href] = true;
          var snippetEl = el.querySelector('[data-sncf], .VwiC3b, [style*="-webkit-line-clamp"]');
          items.push({
            title: (titleEl.textContent || '').trim(),
            url: href,
            snippet: snippetEl ? (snippetEl.textContent || '').trim() : ''
          });
        }
        return items;
      })(${limit})`,
  },
  {
    name: 'Brave',
    buildUrl: (q, limit) => `https://search.brave.com/search?q=${encodeURIComponent(q)}&source=web`,
    domScript: (limit) => `
      (function(maxResults) {
        var items = [];
        var els = document.querySelectorAll('.snippet');
        for (var i = 0; i < els.length; i++) {
          if (items.length >= maxResults) break;
          var el = els[i];
          var linkEl = el.querySelector('.result-header, a.heading[href]');
          if (!linkEl) linkEl = el.querySelector('a[href]');
          var titleEl = el.querySelector('.snippet-title');
          if (!titleEl) titleEl = el.querySelector('h2, h3');
          var snippetEl = el.querySelector('.snippet-description');
          if (!snippetEl) snippetEl = el.querySelector('.snippet-content p, p');
          if (linkEl) {
            var href = linkEl.getAttribute('href') || '';
            if (href.indexOf('/search') === 0 || href.indexOf('brave.com') !== -1) continue;
            var title = titleEl ? (titleEl.textContent || '').trim() : (linkEl.textContent || '').trim();
            if (!title) continue;
            items.push({
              title: title,
              url: href,
              snippet: snippetEl ? (snippetEl.textContent || '').trim() : ''
            });
          }
        }
        return items;
      })(${limit})`,
  },
  {
    name: 'Yahoo',
    buildUrl: (q, limit) => `https://search.yahoo.co.jp/search?p=${encodeURIComponent(q)}&n=${limit}`,
    domScript: (limit) => `
      (function(maxResults) {
        var items = [];
        var els = document.querySelectorAll('#web .algo, #WS2m .sw-Card');
        if (els.length === 0) els = document.querySelectorAll('.Sr');
        for (var i = 0; i < els.length; i++) {
          if (items.length >= maxResults) break;
          var el = els[i];
          var linkEl = el.querySelector('a[href]');
          var titleEl = el.querySelector('h3, .sw-Card__title');
          var snippetEl = el.querySelector('.sw-Card__description, p');
          if (linkEl) {
            var href = linkEl.getAttribute('href') || '';
            var title = titleEl ? (titleEl.textContent || '').trim() : (linkEl.textContent || '').trim();
            if (!title) continue;
            items.push({
              title: title,
              url: href,
              snippet: snippetEl ? (snippetEl.textContent || '').trim() : ''
            });
          }
        }
        return items;
      })(${limit})`,
  },
];

// --- 永続 BrowserContext 管理 (noVNC CAPTCHA 解決用) ---

const persistentContexts = new Map<string, import('playwright').BrowserContext>();
const captchaPages: Array<{ engineName: string; page: import('playwright').Page; createdAt: number }> = [];

async function getOrCreateContext(
  engineName: string,
  browser: import('playwright').Browser,
): Promise<import('playwright').BrowserContext> {
  const existing = persistentContexts.get(engineName);
  if (existing) {
    // コンテキストがまだ有効かチェック（pages() が例外を投げたら無効）
    try {
      if (browser.contexts().includes(existing)) {
        return existing;
      }
    } catch {
      // ignore
    }
    persistentContexts.delete(engineName);
  }
  const ctx = await browser.newContext({
    userAgent: DEFAULT_USER_AGENT,
    locale: 'ja-JP',
  });
  persistentContexts.set(engineName, ctx);
  return ctx;
}

function trackCaptchaPage(
  engineName: string,
  page: import('playwright').Page,
  maxPages: number,
): void {
  captchaPages.push({ engineName, page, createdAt: Date.now() });
  while (captchaPages.length > maxPages) {
    const old = captchaPages.shift();
    old?.page.close().catch(() => {});
  }
}

/** 永続コンテキストと CAPTCHA ページ追跡をすべてクリア */
export function clearPersistentContexts(): void {
  for (const { page } of captchaPages) {
    page.close().catch(() => {});
  }
  captchaPages.length = 0;
  for (const ctx of persistentContexts.values()) {
    ctx.close().catch(() => {});
  }
  persistentContexts.clear();
}

/**
 * CAPTCHA Pool の captchaPending フラグを更新する。
 * SessionManager が import できるなら経由する (循環依存を避けるため動的 require)。
 * - true: WebSearch が CAPTCHA を踏んだとき
 * - false: 検索が成功したとき / admin が手動でクリアしたとき
 */
function markPoolCaptchaPending(pending: boolean): void {
  // 動的 import (top-level だと core / browser-session の循環参照が発生しうる)
  import('./browser.js')
    .then((mod) => {
      const sm = mod.getSessionManager?.();
      sm?.markCaptchaPending?.(pending);
    })
    .catch(() => { /* ignore */ });
}

/** ブラウザで検索エンジンにアクセスして結果を取得する汎用関数 */
async function searchViaBrowser(
  engine: SearchEngineConfig,
  query: string,
  limit: number,
  pageTimeout: number,
  useNovnc: boolean = false,
  maxCaptchaPages: number = 5,
): Promise<{ results: SearchResult[]; captcha: boolean }> {
  const url = engine.buildUrl(query, limit);
  logger.debug(`[WebSearch] ${engine.name} browser search: url=${url}`);

  // WebSearch は CAPTCHA Pool の Browser を共有して使う。これにより admin が
  // 一度 CAPTCHA を解けば Cookie が persistentContexts に残り、別タスクの
  // WebSearch も同じ Cookie で続行できる (タスク隔離が必要な BrowseWeb と
  // 違って、WebSearch は "誰がやっても同じ検索" なので共有して良い)。
  const { getCaptchaPoolBrowser } = await import('./browser.js');
  const browser = await getCaptchaPoolBrowser();

  let context: import('playwright').BrowserContext;
  if (useNovnc) {
    context = await getOrCreateContext(engine.name, browser);
  } else {
    context = await browser.newContext({
      userAgent: DEFAULT_USER_AGENT,
      locale: 'ja-JP',
    });
  }

  let page: import('playwright').Page | null = null;
  try {
    page = await context.newPage();
    page.setDefaultTimeout(pageTimeout);

    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: pageTimeout });

    // 結果コンテナが描画されるまで最大 3 秒待つ（失敗しても続行）。
    // Google は h3 / #search / #rso のいずれかが JS 経由で挿入されるレイアウトがある。
    await page.waitForSelector('h3, #search, #rso', { timeout: 3000 }).catch(() => undefined);

    // タイトルから CAPTCHA 検出
    const pageTitle = await page.evaluate(`document.title || ''`) as string;
    if (CAPTCHA_PATTERN.test(pageTitle)) {
      if (useNovnc) {
        trackCaptchaPage(engine.name, page, maxCaptchaPages);
        page = null; // ページを閉じない — noVNC で可視のまま
        markPoolCaptchaPending(true);
      }
      return { results: [], captcha: true };
    }

    // 段階1: DOM セレクタでパース
    const domResults = await page.evaluate(engine.domScript(limit)) as SearchResult[];
    if (domResults.length > 0) {
      return { results: domResults, captcha: false };
    }

    // 段階2: テキストフォールバック
    const bodyText = await page.evaluate(
      `(document.body && document.body.innerText || '').slice(0, 5000)`
    ) as string;

    if (CAPTCHA_PATTERN.test(bodyText)) {
      if (useNovnc) {
        trackCaptchaPage(engine.name, page, maxCaptchaPages);
        page = null;
        markPoolCaptchaPending(true);
      }
      return { results: [], captcha: true };
    }

    const textResults = parseSearchResultsFromText(bodyText, limit);
    return { results: textResults, captcha: false };
  } finally {
    if (page) {
      await page.close().catch(() => {});
    }
    if (!useNovnc) {
      await context.close().catch(() => {});
    }
  }
}

/** Playwright の TimeoutError を判別 */
function isTimeoutError(e: unknown): boolean {
  return e instanceof Error && (
    e.constructor.name === 'TimeoutError' || e.message.includes('Timeout')
  );
}

async function executeWebSearch(
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult> {
  const rawQuery = input['query'] as string;
  const rawLimit = typeof input['limit'] === 'number' ? input['limit'] : 5;
  const limit = Math.min(Math.max(1, rawLimit), 20);

  // 検索クエリの機密情報フィルタリング
  const filterConfig = ctx.searchFilter ?? ctx.toolsConfig?.searchFilter ?? {};
  const sanitized = sanitizeQuery(rawQuery, filterConfig);
  if (sanitized === null) {
    return { output: '検索クエリが機密情報のみで構成されています。クエリを見直してください。', isError: true };
  }
  const query = sanitized;
  if (sanitized !== rawQuery) {
    logger.info(`[WebSearch] クエリに機密情報が含まれていたためフィルタを適用しました`);
  }

  const pageTimeout = ctx.toolsConfig?.browserPageTimeout ?? 60000;

  const { loadConfig } = await import('../../config.js');
  const appConfig = loadConfig();
  const useNovnc = (appConfig.browser?.displayMode ?? 'headless') === 'novnc';
  const maxCaptchaPages = appConfig.browser?.maxCaptchaPages ?? 5;

  // --- ブラウザ検索: Google → Brave → Yahoo の順に試行 ---
  let lastBrowserError: string | null = null;

  for (let i = 0; i < SEARCH_ENGINES.length; i++) {
    const engine = SEARCH_ENGINES[i]!;
    const isFallback = i > 0;
    const methodName = engine.name.toLowerCase();

    try {
      const { results, captcha } = await searchViaBrowser(engine, query, limit, pageTimeout, useNovnc, maxCaptchaPages);

      if (captcha) {
        lastBrowserError = `${engine.name} が CAPTCHA を要求しました`;
        logger.warn(`[WebSearch] ${lastBrowserError}, trying next engine`);
        appendWebSearchHistory(ctx, {
          timestamp: new Date().toISOString(),
          query, limit, method: methodName,
          resultCount: 0, outcome: 'captcha', fallback: isFallback,
        });
        continue;
      }

      if (results.length > 0) {
        const formatted = formatResults(results);
        appendWebSearchHistory(ctx, {
          timestamp: new Date().toISOString(),
          query, limit, method: methodName,
          resultCount: results.length, outcome: 'success', fallback: isFallback,
        });
        // 検索が通った = Pool は CAPTCHA を抜けている。フラグを下ろす
        if (useNovnc) markPoolCaptchaPending(false);
        return { output: formatted, isError: false };
      }

      // 結果0件 — 次のエンジンへ
      appendWebSearchHistory(ctx, {
        timestamp: new Date().toISOString(),
        query, limit, method: methodName,
        resultCount: 0, outcome: 'success', fallback: isFallback,
      });
      lastBrowserError = `${engine.name}: 結果なし`;
      logger.info(`[WebSearch] ${engine.name} returned 0 results, trying next engine`);
    } catch (e) {
      const errMsg = (e as Error).message ?? String(e);
      const outcome = isTimeoutError(e) ? 'timeout' as const : 'error' as const;
      lastBrowserError = `${engine.name}: ${errMsg}`;
      logger.warn(`[WebSearch] ${engine.name} failed: ${lastBrowserError}, trying next engine`);
      appendWebSearchHistory(ctx, {
        timestamp: new Date().toISOString(),
        query, limit, method: methodName,
        resultCount: 0, outcome, fallback: isFallback,
        error: errMsg,
      });
      // Playwright 未インストール等のブラウザ起動エラーは残りのエンジンでも同じ結果になるため即スキップ
      if (errMsg.includes("Executable doesn't exist") || errMsg.includes('browserType.launch') || errMsg.includes('Playwright browser unavailable')) {
        logger.info(`[WebSearch] browser unavailable, skipping remaining engines and falling back to SearXNG`);
        break;
      }
    }
  }

  // --- 最終フォールバック: SearXNG ---
  try {
    const results = await searchViaSearxng(query, limit, ctx);
    if (results.length === 0) {
      appendWebSearchHistory(ctx, {
        timestamp: new Date().toISOString(),
        query, limit, method: 'searxng',
        resultCount: 0, outcome: 'success', fallback: true,
      });
      return { output: '(検索結果なし)', isError: false };
    }
    const formatted = formatResults(results);
    appendWebSearchHistory(ctx, {
      timestamp: new Date().toISOString(),
      query, limit, method: 'searxng',
      resultCount: results.length, outcome: 'success', fallback: true,
    });
    return { output: formatted, isError: false };
  } catch (e) {
    const searxngError = (e as Error).message ?? String(e);
    logger.warn(`[WebSearch] SearXNG fallback also failed: ${searxngError}`);
    appendWebSearchHistory(ctx, {
      timestamp: new Date().toISOString(),
      query, limit, method: 'searxng',
      resultCount: 0, outcome: 'error', fallback: true,
      error: searxngError,
    });
    return { output: `WebSearch error: 全検索エンジンが失敗しました。最後のエラー: ${lastBrowserError}`, isError: true };
  }
}

function formatResults(results: SearchResult[]): string {
  return results
    .map((r, i) => {
      const title = r.title || '(タイトルなし)';
      return `[${i + 1}] ${title}\n    ${r.url}\n    ${r.snippet}`;
    })
    .join('\n\n');
}

// --- WebFetch 実装 ---

export type SniffResult =
  | { binary: true; reason: string }
  | { binary: false; text: string; truncated: boolean };

/**
 * Stream a fetch Response body, sniff the first SNIFF_HEAD_BYTES for binary
 * content, and either block (binary) or strict-decode the (capped) text body.
 * Never uses response.text() — that silently produces U+FFFD from binary.
 */
export async function sniffAndDecodeBody(
  response: { body: ReadableStream<Uint8Array> | null },
): Promise<SniffResult> {
  const reader = response.body?.getReader();
  if (!reader) return { binary: false, text: '', truncated: false };

  const chunks: Buffer[] = [];
  let total = 0;
  let verdict: BinaryVerdict | null = null;
  let truncated = false;

  try {
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      if (!value || value.byteLength === 0) continue;
      chunks.push(Buffer.from(value));
      total += value.byteLength;

      if (!verdict && total >= SNIFF_HEAD_BYTES) {
        const head = Buffer.concat(chunks).subarray(0, SNIFF_HEAD_BYTES);
        verdict = looksLikeBinaryBytes(head);
        if (verdict.binary) {
          await reader.cancel();
          return { binary: true, reason: verdict.reason };
        }
      }

      if (total >= MAX_WEBFETCH_BODY_BYTES) {
        truncated = true;
        await reader.cancel();
        break;
      }
    }
  } finally {
    try { reader.releaseLock(); } catch { /* cancel() may or may not have released the lock depending on runtime */ }
  }

  let full = Buffer.concat(chunks);
  if (truncated) full = full.subarray(0, MAX_WEBFETCH_BODY_BYTES);

  if (!verdict) {
    verdict = looksLikeBinaryBytes(full.subarray(0, SNIFF_HEAD_BYTES));
    if (verdict.binary) return { binary: true, reason: verdict.reason };
  }

  const text = decodeText(full, verdict.encoding);
  if (text === null) return { binary: true, reason: 'utf8-decode-fail' };
  return { binary: false, text, truncated };
}

async function executeWebFetch(
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult> {
  const rawUrl = input['url'] as string;
  // selector パラメータは受け取るが、cheerio なしでは CSS セレクト不可のため無視
  // (仕様上 selector は省略可能で、対応できない旨をログに残す)
  const selector = input['selector'] as string | undefined;

  const timeoutSec = ctx.toolsConfig?.webfetchTimeout ?? 30;
  const allowedHosts = ctx.toolsConfig?.webfetchAllowedHosts ?? [];

  // URL パース
  let parsedUrl: URL;
  try {
    parsedUrl = new URL(rawUrl);
  } catch {
    appendWebFetchHistory(ctx, {
      timestamp: new Date().toISOString(),
      url: rawUrl,
      selector,
      status: null,
      contentType: null,
      outcome: 'invalid_url',
      error: `invalid URL "${rawUrl}"`,
    });
    return { output: `WebFetch error: invalid URL "${rawUrl}"`, isError: true };
  }

  const { hostname } = parsedUrl;

  // SSRF チェック
  try {
    await checkSSRF(hostname, allowedHosts);
  } catch (e) {
    const error = (e as Error).message;
    appendWebFetchHistory(ctx, {
      timestamp: new Date().toISOString(),
      url: rawUrl,
      selector,
      status: null,
      contentType: null,
      outcome: 'ssrf_blocked',
      error,
    });
    return { output: `WebFetch error: ${error}`, isError: true };
  }

  if (selector) {
    logger.debug(`[WebFetch] selector "${selector}" is specified but CSS selection is not supported without cheerio — fetching full page`);
  }

  logger.debug(`[WebFetch] fetching ${rawUrl}`);

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutSec * 1000);

  try {
    // ssrfSafeFetch re-validates each redirect hop so a public URL cannot
    // 30x-bounce into a private/metadata address.
    const response = await ssrfSafeFetch(rawUrl, allowedHosts, { signal: controller.signal });
    clearTimeout(timer);

    if (!response.ok) {
      appendWebFetchHistory(ctx, {
        timestamp: new Date().toISOString(),
        url: rawUrl,
        selector,
        status: response.status,
        contentType: response.headers.get('content-type'),
        outcome: 'http_error',
        error: `${response.status} ${response.statusText}`,
      });
      return {
        output: `WebFetch error: HTTP ${response.status} ${response.statusText}. 403/429/5xx の場合は動的レンダリングや bot 検出の可能性があるので BrowseWeb で再試行してください。`,
        isError: true,
      };
    }

    const contentType = response.headers.get('content-type') ?? '';
    if (isPdfResponse(rawUrl, contentType)) {
      appendWebFetchHistory(ctx, {
        timestamp: new Date().toISOString(),
        url: rawUrl,
        selector,
        status: response.status,
        contentType,
        outcome: 'pdf_blocked',
        error: 'PDF content blocked',
      });
      return {
        output: `WebFetch cannot read PDF content from "${rawUrl}". Use ReadPdf for local PDF files instead of fetching the binary document as text.`,
        isError: true,
      };
    }
    if (isLikelyBinaryResponse(contentType)) {
      appendWebFetchHistory(ctx, {
        timestamp: new Date().toISOString(),
        url: rawUrl,
        selector,
        status: response.status,
        contentType,
        outcome: 'binary_blocked',
        error: `binary content-type "${contentType || 'unknown'}"`,
      });
      return {
        output: `WebFetch cannot read binary content-type "${contentType || 'unknown'}" from "${rawUrl}". Fetch a text/HTML endpoint instead.`,
        isError: true,
      };
    }

    const sniffed = await sniffAndDecodeBody(response);
    if (sniffed.binary) {
      appendWebFetchHistory(ctx, {
        timestamp: new Date().toISOString(),
        url: rawUrl,
        selector,
        status: response.status,
        contentType,
        outcome: 'binary_blocked',
        error: `binary content detected (${sniffed.reason})`,
      });
      return {
        output: `WebFetch blocked binary content from "${rawUrl}" (detected: ${sniffed.reason}). コンテキストに展開していません。DownloadFile で input/ に保存し、ReadExcel/ReadPdf 等で処理してください。`,
        isError: true,
      };
    }
    const text =
      htmlToText(sniffed.text) +
      (sniffed.truncated ? '\n\n[truncated: body exceeded 5MB]' : '');

    // vlmEnabled 時はファーストビューのスクショを並行取得して画像を添付する。
    // 失敗時は警告ログのみで WebFetch 自体は成功扱いとする。
    const screenshot = await captureWebFetchScreenshot(ctx, rawUrl);

    appendWebFetchHistory(ctx, {
      timestamp: new Date().toISOString(),
      url: rawUrl,
      selector,
      status: response.status,
      contentType,
      outcome: 'success',
      ...(screenshot ? { screenshotPath: path.relative(ctx.workspacePath, screenshot.savedPath) } : {}),
    });

    const images = screenshot
      ? [{ dataUrl: screenshot.dataUrl, label: `WebFetch preview: ${rawUrl}` }]
      : undefined;
    return { output: text, isError: false, ...(images ? { images } : {}) };
  } catch (e) {
    clearTimeout(timer);
    const msg = (e as Error).message ?? String(e);
    logger.warn(`[WebFetch] error: ${msg}`);
    appendWebFetchHistory(ctx, {
      timestamp: new Date().toISOString(),
      url: rawUrl,
      selector,
      status: null,
      contentType: null,
      outcome: 'error',
      error: msg,
    });
    return {
      output: `WebFetch error: ${msg}. 接続拒否・タイムアウト・動的ページの可能性があれば BrowseWeb で再試行してください。`,
      isError: true,
    };
  }
}

// --- DownloadFile 実装 ---

async function executeDownloadFile(
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult> {
  const rawUrl = input['url'] as string;
  const rawSection = typeof input['section'] === 'string' ? input['section'] : 'input';
  const section = rawSection === 'output' ? 'output' : 'input';
  const timeoutSec = ctx.toolsConfig?.webfetchTimeout ?? 60;
  const allowedHosts = ctx.toolsConfig?.webfetchAllowedHosts ?? [];

  // URL パース
  let parsedUrl: URL;
  try {
    parsedUrl = new URL(rawUrl);
  } catch {
    appendDownloadHistory(ctx, {
      timestamp: new Date().toISOString(), url: rawUrl, savedPath: null,
      status: null, contentType: null, bytes: null, outcome: 'invalid_url',
      error: `invalid URL "${rawUrl}"`,
    });
    return { output: `DownloadFile error: invalid URL "${rawUrl}"`, isError: true };
  }

  const { hostname } = parsedUrl;

  // SSRF チェック
  try {
    await checkSSRF(hostname, allowedHosts);
  } catch (e) {
    const error = (e as Error).message;
    appendDownloadHistory(ctx, {
      timestamp: new Date().toISOString(), url: rawUrl, savedPath: null,
      status: null, contentType: null, bytes: null, outcome: 'ssrf_blocked', error,
    });
    return { output: `DownloadFile error: ${error}`, isError: true };
  }

  // ファイル名解決
  let filename: string;
  if (typeof input['filename'] === 'string' && input['filename'].trim()) {
    filename = input['filename'].trim().replace(/[\\/:*?"<>|]/g, '_');
  } else {
    const basename = path.basename(parsedUrl.pathname) || 'download';
    filename = basename.replace(/[\\/:*?"<>|]/g, '_') || 'download';
  }

  // 保存先パス（output/ or input/ 配下に制限）
  let resolvedSavePath: string;
  try {
    resolvedSavePath = resolveOutputPathWithin(ctx.workspacePath, path.join(section, filename), [section]);
  } catch (e) {
    return { output: (e as Error).message, isError: true };
  }

  logger.debug(`[DownloadFile] downloading ${rawUrl} -> ${section}/${filename}`);

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutSec * 1000);

  try {
    const response = await ssrfSafeFetch(rawUrl, allowedHosts, { signal: controller.signal });
    clearTimeout(timer);

    if (!response.ok) {
      appendDownloadHistory(ctx, {
        timestamp: new Date().toISOString(), url: rawUrl, savedPath: null,
        status: response.status, contentType: response.headers.get('content-type'),
        bytes: null, outcome: 'http_error',
        error: `${response.status} ${response.statusText}`,
      });
      return { output: `DownloadFile error: HTTP ${response.status} ${response.statusText}`, isError: true };
    }

    const contentType = response.headers.get('content-type') ?? '';
    const buf = Buffer.from(await response.arrayBuffer());

    fs.mkdirSync(path.dirname(resolvedSavePath), { recursive: true });
    fs.writeFileSync(resolvedSavePath, buf);

    appendDownloadHistory(ctx, {
      timestamp: new Date().toISOString(), url: rawUrl,
      savedPath: `${section}/${filename}`,
      status: response.status, contentType,
      bytes: buf.length, outcome: 'success',
    });

    return {
      output: `Downloaded ${buf.length} bytes from ${rawUrl} -> ${section}/${filename} (content-type: ${contentType || 'unknown'})`,
      isError: false,
    };
  } catch (e) {
    clearTimeout(timer);
    const msg = (e as Error).message ?? String(e);
    logger.warn(`[DownloadFile] error: ${msg}`);
    appendDownloadHistory(ctx, {
      timestamp: new Date().toISOString(), url: rawUrl, savedPath: null,
      status: null, contentType: null, bytes: null, outcome: 'error', error: msg,
    });
    return { output: `DownloadFile error: ${msg}`, isError: true };
  }
}

// --- エクスポート ---

export const TOOL_DEFS: Record<string, ToolDef> = {
  WebSearch: WEBSEARCH_DEF,
  WebFetch: WEBFETCH_DEF,
  DownloadFile: DOWNLOADFILE_DEF,
};

export async function executeTool(
  name: string,
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult | null> {
  switch (name) {
    case 'WebSearch':
      return executeWebSearch(input, ctx);
    case 'WebFetch':
      return executeWebFetch(input, ctx);
    case 'DownloadFile':
      return executeDownloadFile(input, ctx);
    default:
      return null;
  }
}
