import { mkdir } from "node:fs/promises";
import path from "node:path";

const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const MAX_SEARCH_RESULTS = 10;
const MAX_RESEARCH_SOURCES = 8;

type FetchLike = typeof fetch;

export type WebSearchResult = {
  rank: number;
  title: string;
  source_url: string;
  snippet: string;
};

export type WebExtractResult = {
  source_url: string;
  final_url: string;
  title: string;
  description: string;
  content: string;
  tables: Array<{
    source_url: string;
    columns: string[];
    rows: string[][];
  }>;
  structured_items: Array<Record<string, string>>;
  screenshot_path?: string;
  quality: {
    passed: boolean;
    content_chars: number;
    table_count: number;
    structured_item_count: number;
    required_texts_found: string[];
    required_texts_missing: string[];
    warnings: string[];
  };
  extraction: {
    mode: "static" | "playwright";
    fetched_at: string;
    content_type: string;
    truncated: boolean;
    duration_ms: number;
  };
};

function asInt(value: unknown, fallback: number, max: number): number {
  const parsed = Number(value);
  return Number.isFinite(parsed)
    ? Math.min(max, Math.max(1, Math.floor(parsed)))
    : fallback;
}

function assertPublicHttpUrl(raw: string): URL {
  let url: URL;
  try {
    url = new URL(raw);
  } catch {
    throw new Error(`Invalid URL: ${raw}`);
  }
  if (url.protocol !== "http:" && url.protocol !== "https:") {
    throw new Error(`Only http/https URLs are supported: ${raw}`);
  }
  const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
  const privateHost =
    host === "localhost" ||
    host === "::1" ||
    host === "0.0.0.0" ||
    /^127\./.test(host) ||
    /^10\./.test(host) ||
    /^192\.168\./.test(host) ||
    /^169\.254\./.test(host) ||
    /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
    /^(fc|fd|fe80):/i.test(host);
  if (privateHost) {
    throw new Error(`Private or local URLs are not allowed: ${raw}`);
  }
  return url;
}

function decodeHtml(value: string): string {
  const named: Record<string, string> = {
    amp: "&",
    quot: '"',
    apos: "'",
    lt: "<",
    gt: ">",
    nbsp: " ",
    ndash: "-",
    mdash: "-",
  };
  return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, entity: string) => {
    if (entity.startsWith("#x")) {
      return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
    }
    if (entity.startsWith("#")) {
      return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
    }
    return named[entity.toLowerCase()] ?? whole;
  });
}

function stripHtml(value: string): string {
  return decodeHtml(
    value
      .replace(/<br\s*\/?>/gi, "\n")
      .replace(/<[^>]+>/g, " "),
  )
    .replace(/[\t\r ]+/g, " ")
    .replace(/ *\n */g, "\n")
    .trim();
}

function attrValue(tag: string, name: string): string {
  const match = tag.match(new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, "i"));
  return match ? decodeHtml(match[1] ?? "") : "";
}

function normalizeDuckDuckGoUrl(raw: string): string {
  const decoded = decodeHtml(raw);
  try {
    const url = new URL(decoded, "https://duckduckgo.com");
    const target = url.searchParams.get("uddg");
    return target ? decodeURIComponent(target) : url.href;
  } catch {
    return decoded;
  }
}

async function fetchText(
  url: URL,
  init: RequestInit,
  fetchImpl: FetchLike = fetch,
): Promise<{ text: string; finalUrl: string; contentType: string; truncated: boolean }> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
  try {
    const response = await fetchImpl(url, {
      ...init,
      redirect: "follow",
      signal: controller.signal,
      headers: {
        "user-agent": "CodeFlowMu-WebResearch/1.0 (+https://github.com/joinwell52-AI/CodeFlowMu)",
        accept: "text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.1",
        ...(init.headers ?? {}),
      },
    });
    if (!response.ok) {
      throw new Error(`HTTP ${response.status} ${response.statusText} for ${url.href}`);
    }
    const finalUrl = response.url || url.href;
    assertPublicHttpUrl(finalUrl);
    const buffer = Buffer.from(await response.arrayBuffer());
    const truncated = buffer.byteLength > MAX_RESPONSE_BYTES;
    return {
      text: buffer.subarray(0, MAX_RESPONSE_BYTES).toString("utf8"),
      finalUrl,
      contentType: response.headers.get("content-type") ?? "",
      truncated,
    };
  } finally {
    clearTimeout(timer);
  }
}

export async function webSearch(
  args: Record<string, unknown>,
  fetchImpl: FetchLike = fetch,
): Promise<Record<string, unknown>> {
  const query = String(args.query ?? "").trim();
  if (!query) throw new Error("web_search requires query");
  const limit = asInt(args.limit, 5, MAX_SEARCH_RESULTS);
  const domainList = Array.isArray(args.domains)
    ? args.domains.map(String).map((s) => s.trim()).filter(Boolean)
    : [];
  const effectiveQuery = domainList.length
    ? `${query} (${domainList.map((d) => `site:${d}`).join(" OR ")})`
    : query;
  const searchUrl = new URL("https://html.duckduckgo.com/html/");
  searchUrl.searchParams.set("q", effectiveQuery);
  const fetched = await fetchText(searchUrl, { method: "GET" }, fetchImpl);
  const results: WebSearchResult[] = [];
  const linkRe = /<a\b[^>]*class=["'][^"']*result__a[^"']*["'][^>]*>[\s\S]*?<\/a>/gi;
  for (const tag of fetched.text.match(linkRe) ?? []) {
    if (results.length >= limit) break;
    const href = normalizeDuckDuckGoUrl(attrValue(tag, "href"));
    if (!href) continue;
    try {
      assertPublicHttpUrl(href);
    } catch {
      continue;
    }
    const index = fetched.text.indexOf(tag);
    const nearby = fetched.text.slice(index + tag.length, index + tag.length + 1_500);
    const snippetMatch = nearby.match(/class=["'][^"']*result__snippet[^"']*["'][^>]*>([\s\S]*?)<\/[^>]+>/i);
    results.push({
      rank: results.length + 1,
      title: stripHtml(tag),
      source_url: href,
      snippet: snippetMatch ? stripHtml(snippetMatch[1] ?? "") : "",
    });
  }
  return {
    query,
    effective_query: effectiveQuery,
    engine: "duckduckgo_html",
    searched_at: new Date().toISOString(),
    results,
  };
}

function extractTables(html: string, sourceUrl: string): WebExtractResult["tables"] {
  const tables: WebExtractResult["tables"] = [];
  for (const tableHtml of html.match(/<table\b[\s\S]*?<\/table>/gi) ?? []) {
    const rows = (tableHtml.match(/<tr\b[\s\S]*?<\/tr>/gi) ?? [])
      .map((row) => (row.match(/<(?:th|td)\b[^>]*>[\s\S]*?<\/(?:th|td)>/gi) ?? []).map(stripHtml))
      .filter((row) => row.some(Boolean));
    if (rows.length === 0) continue;
    const first = rows[0] ?? [];
    const hasHeader = /<th\b/i.test(tableHtml.match(/<tr\b[\s\S]*?<\/tr>/i)?.[0] ?? "");
    const columns = hasHeader ? first : first.map((_, i) => `column_${i + 1}`);
    tables.push({ source_url: sourceUrl, columns, rows: hasHeader ? rows.slice(1) : rows });
  }
  return tables;
}

function extractDocument(html: string, sourceUrl: string, finalUrl: string, contentType: string, truncated: boolean, mode: "static" | "playwright"): WebExtractResult {
  const title = stripHtml(html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? "");
  const descriptionTag = html.match(/<meta\b[^>]*(?:name|property)=["'](?:description|og:description)["'][^>]*>/i)?.[0] ?? "";
  const description = attrValue(descriptionTag, "content");
  const main = html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i)?.[1]
    ?? html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i)?.[1]
    ?? html.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i)?.[1]
    ?? html;
  const cleaned = main
    .replace(/<(script|style|noscript|svg|nav|header|footer|form)\b[\s\S]*?<\/\1>/gi, " ")
    .replace(/<(p|div|section|article|h[1-6]|li|blockquote|pre|tr)\b[^>]*>/gi, "\n")
    .replace(/<br\s*\/?>/gi, "\n");
  const content = stripHtml(cleaned).replace(/\n{3,}/g, "\n\n").slice(0, 100_000);
  return {
    source_url: sourceUrl,
    final_url: finalUrl,
    title,
    description,
    content,
    tables: extractTables(html, finalUrl),
    structured_items: [],
    quality: {
      passed: content.length >= 200,
      content_chars: content.length,
      table_count: extractTables(html, finalUrl).length,
      structured_item_count: 0,
      required_texts_found: [],
      required_texts_missing: [],
      warnings: content.length >= 200 ? [] : ["Extracted content is unusually short."],
    },
    extraction: {
      mode,
      fetched_at: new Date().toISOString(),
      content_type: contentType,
      truncated: truncated || content.length >= 100_000,
      duration_ms: 0,
    },
  };
}

function stringArray(value: unknown, max = 20): string[] {
  if (!Array.isArray(value)) return [];
  return value.map(String).map((item) => item.trim()).filter(Boolean).slice(0, max);
}

function safeScreenshotPath(projectRoot: string | undefined, raw: unknown): string | undefined {
  const value = String(raw ?? "").trim();
  if (!value) return undefined;
  if (!projectRoot) throw new Error("screenshot_path requires a project root");
  const root = path.resolve(projectRoot);
  const target = path.isAbsolute(value) ? path.resolve(value) : path.resolve(root, value);
  const rel = path.relative(root, target);
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
    throw new Error(`screenshot_path escapes project root: ${value}`);
  }
  return target;
}

function applyQualityGate(result: WebExtractResult, args: Record<string, unknown>): WebExtractResult {
  const tableContains = stringArray(args.table_contains);
  if (tableContains.length) {
    result.tables = result.tables.filter((table) => {
      const text = `${table.columns.join(" ")} ${table.rows.flat().join(" ")}`;
      return tableContains.every((term) => text.includes(term));
    });
  }
  const maxTables = Math.min(100, Math.max(1, Number(args.max_tables) || 100));
  const maxTableRows = Math.min(2_000, Math.max(1, Number(args.max_table_rows) || 500));
  result.tables = result.tables.slice(0, maxTables).map((table) => ({
    ...table,
    rows: table.rows.slice(0, maxTableRows),
  }));
  const requiredTexts = stringArray(args.required_texts);
  const found = requiredTexts.filter((item) => result.content.includes(item));
  const missing = requiredTexts.filter((item) => !result.content.includes(item));
  const minChars = Math.max(0, Number(args.min_content_chars) || 200);
  const minTables = Math.max(0, Number(args.min_tables) || 0);
  const minItems = Math.max(0, Number(args.min_structured_items) || 0);
  const warnings = [...result.quality.warnings];
  if (result.content.length < minChars) warnings.push(`content_chars ${result.content.length} < required ${minChars}`);
  if (result.tables.length < minTables) warnings.push(`table_count ${result.tables.length} < required ${minTables}`);
  if (result.structured_items.length < minItems) warnings.push(`structured_item_count ${result.structured_items.length} < required ${minItems}`);
  if (missing.length) warnings.push(`Missing required text: ${missing.join(", ")}`);
  result.quality = {
    passed:
      result.content.length >= minChars &&
      result.tables.length >= minTables &&
      result.structured_items.length >= minItems &&
      missing.length === 0,
    content_chars: result.content.length,
    table_count: result.tables.length,
    structured_item_count: result.structured_items.length,
    required_texts_found: found,
    required_texts_missing: missing,
    warnings,
  };
  if (!result.quality.passed && (args.fail_on_quality === true || args.fail_on_quality === "true")) {
    throw new Error(`WEB_EXTRACT_QUALITY_FAILED: ${warnings.join("; ")}`);
  }
  return result;
}

async function extractWithPlaywright(
  url: URL,
  args: Record<string, unknown>,
  projectRoot?: string,
): Promise<WebExtractResult> {
  const startedAt = Date.now();
  const moduleName = "playwright";
  const playwright = await import(moduleName) as typeof import("playwright");
  const browser = await playwright.chromium.launch({ headless: true });
  try {
    const page = await browser.newPage({
      userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36 CodeFlowMu-WebResearch/1.0",
      locale: String(args.locale ?? "zh-CN"),
      viewport: { width: 1440, height: 1000 },
    });
    const timeout = Math.min(90_000, Math.max(5_000, Number(args.timeout_ms) || DEFAULT_TIMEOUT_MS));
    await page.goto(url.href, { waitUntil: "domcontentloaded", timeout });
    const waitForSelector = String(args.wait_for_selector ?? "").trim();
    const waitForText = String(args.wait_for_text ?? "").trim();
    if (waitForSelector) await page.locator(waitForSelector).first().waitFor({ state: "visible", timeout });
    if (waitForText) {
      await page.waitForFunction(
        (text) => (document.body?.innerText ?? "").includes(String(text)),
        waitForText,
        { timeout },
      );
    }
    for (const clickText of stringArray(args.click_texts, 10)) {
      const matches = page.getByText(clickText, { exact: false });
      for (let index = 0; index < await matches.count(); index++) {
        const target = matches.nth(index);
        if (await target.isVisible()) {
          await target.click({ timeout: Math.min(timeout, 8_000) });
          break;
        }
      }
    }
    if (args.auto_scroll === true || args.auto_scroll === "true") {
      await page.evaluate(async () => {
        for (let y = 0; y < document.body.scrollHeight; y += 800) {
          window.scrollTo(0, y);
          await new Promise((resolve) => setTimeout(resolve, 60));
        }
        window.scrollTo(0, 0);
      });
    }
    const settleMs = Math.min(5_000, Math.max(0, Number(args.settle_ms) || 500));
    if (settleMs) await page.waitForTimeout(settleMs);
    const finalUrl = page.url();
    assertPublicHttpUrl(finalUrl);
    const html = await page.content();
    const result = extractDocument(html, url.href, finalUrl, "text/html; dynamic=playwright", false, "playwright");
    const itemSelector = String(args.item_selector ?? "").trim();
    const fieldSelectors = args.field_selectors && typeof args.field_selectors === "object" && !Array.isArray(args.field_selectors)
      ? args.field_selectors as Record<string, unknown>
      : {};
    if (itemSelector) {
      result.structured_items = await page.locator(itemSelector).evaluateAll((nodes, rawFields) => {
        const fields = rawFields as Record<string, string>;
        return nodes.slice(0, 500).map((node) => {
          const row: Record<string, string> = {};
          for (const [name, selector] of Object.entries(fields)) {
            const hit = selector === ":scope" ? node : node.querySelector(selector);
            row[name] = (hit?.textContent ?? "").replace(/\s+/g, " ").trim();
          }
          if (Object.keys(fields).length === 0) {
            row.text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
          }
          return row;
        }).filter((row) => Object.values(row).some(Boolean));
      }, Object.fromEntries(Object.entries(fieldSelectors).map(([key, value]) => [key, String(value)])));
    }
    const screenshotPath = safeScreenshotPath(projectRoot, args.screenshot_path);
    if (screenshotPath) {
      await mkdir(path.dirname(screenshotPath), { recursive: true });
      await page.screenshot({ path: screenshotPath, fullPage: args.full_page !== false });
      result.screenshot_path = screenshotPath;
    }
    result.extraction.duration_ms = Date.now() - startedAt;
    return applyQualityGate(result, args);
  } finally {
    await browser.close();
  }
}

export async function webExtract(
  args: Record<string, unknown>,
  fetchImpl: FetchLike = fetch,
  projectRoot?: string,
): Promise<WebExtractResult> {
  const rawUrl = String(args.url ?? args.source_url ?? "").trim();
  if (!rawUrl) throw new Error("web_extract requires url");
  const url = assertPublicHttpUrl(rawUrl);
  if (args.dynamic === true || args.dynamic === "true") {
    return extractWithPlaywright(url, args, projectRoot);
  }
  const startedAt = Date.now();
  const fetched = await fetchText(url, { method: "GET" }, fetchImpl);
  const result = extractDocument(
    fetched.text,
    url.href,
    fetched.finalUrl,
    fetched.contentType,
    fetched.truncated,
    "static",
  );
  result.extraction.duration_ms = Date.now() - startedAt;
  return applyQualityGate(result, args);
}

function reportMarkdown(question: string, sources: WebExtractResult[]): string {
  const lines = [`# Web Research Report`, "", `## Question`, "", question, "", `## Evidence`];
  for (const [index, source] of sources.entries()) {
    const excerpt = source.content.slice(0, 1_200).trim();
    lines.push(
      "",
      `### ${index + 1}. ${source.title || source.final_url}`,
      "",
      excerpt || "(No readable body text extracted.)",
      "",
      `Source: ${source.source_url}`,
    );
  }
  lines.push("", "## Sources", "");
  sources.forEach((source, index) => lines.push(`${index + 1}. ${source.source_url}`));
  return lines.join("\n");
}

export async function webResearch(
  args: Record<string, unknown>,
  fetchImpl: FetchLike = fetch,
): Promise<Record<string, unknown>> {
  const question = String(args.question ?? "").trim();
  if (!question) throw new Error("web_research requires question");
  const requestedQueries = Array.isArray(args.queries)
    ? args.queries.map(String).map((q) => q.trim()).filter(Boolean)
    : [];
  const queries = requestedQueries.length ? requestedQueries.slice(0, 4) : [question];
  const maxSources = asInt(args.max_sources, 5, MAX_RESEARCH_SOURCES);
  const candidates: WebSearchResult[] = [];
  for (const query of queries) {
    const search = await webSearch({ query, limit: maxSources }, fetchImpl);
    candidates.push(...((search.results as WebSearchResult[]) ?? []));
  }
  const unique = [...new Map(candidates.map((result) => [result.source_url, result])).values()]
    .slice(0, maxSources);
  const settled = await Promise.allSettled(
    unique.map((result) => webExtract({ url: result.source_url, dynamic: args.dynamic }, fetchImpl)),
  );
  const sources = settled
    .filter((result): result is PromiseFulfilledResult<WebExtractResult> => result.status === "fulfilled")
    .map((result) => result.value);
  const failures = settled
    .map((result, index) => ({ result, candidate: unique[index] }))
    .filter((item): item is { result: PromiseRejectedResult; candidate: WebSearchResult | undefined } => item.result.status === "rejected")
    .map((item) => ({
      source_url: item.candidate?.source_url ?? "",
      error: item.result.reason instanceof Error ? item.result.reason.message : String(item.result.reason),
    }));
  return {
    question,
    queries,
    generated_at: new Date().toISOString(),
    source_urls: sources.map((source) => source.source_url),
    sources,
    failures,
    report_markdown: reportMarkdown(question, sources),
    synthesis_instruction:
      "Use only the extracted evidence above for conclusions. Cite source_url for every material claim and state uncertainty when evidence is insufficient.",
  };
}

export async function invokeGeminiWebResearchTool(
  projectRoot: string,
  toolName: "web_search" | "web_extract" | "web_research",
  args: Record<string, unknown>,
): Promise<string> {
  const value = toolName === "web_search"
    ? await webSearch(args)
    : toolName === "web_extract"
      ? await webExtract(args, fetch, projectRoot)
      : await webResearch(args);
  return JSON.stringify(value, null, 2);
}
