import { BestAgentError, truncateText } from "@best-agent/foundation";
import type { ToolInputValue, TurnId } from "@best-agent/protocol";
import { execFile, execFileSync } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import { readdirSync, statSync, type Dirent } from "node:fs";
import { basename, dirname, relative, resolve } from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
import type { ThreadStore } from "../../core/thread-store.js";
import { classifySensitivePath } from "../../policy/sensitive-resource-policy.js";
import type { ToolDefinition, ToolInputAdapter, ToolExecutionContext } from "../contracts.js";
import { typedToolInputSchema } from "../schema.js";
import {
  type ContentSearchInput,
  type WorkspaceFilesInput,
  type WorkspaceSearchInput,
  CONTENT_SEARCH_LINE_PREVIEW_CHARS,
  CONTENT_SEARCH_MAX_FILE_BYTES,
  WORKSPACE_FILES_DEFAULT_LIMIT,
  WORKSPACE_FILES_IGNORED_DIRECTORIES,
  WORKSPACE_FILES_MAX_LIMIT,
  isPlainObject,
  parseToolInputJsonValue,
  readIntegerField,
  readStringField,
  splitTextFileLines,
  stripProviderToolNoise,
} from "./shared.js";

const execFileAsync = promisify(execFile);
const CONTENT_SEARCH_MAX_EXPLICIT_FILE_BYTES = 16 * 1024 * 1024;
const WORKSPACE_FILES_HEARTBEAT_INTERVAL_MS = 100;
const WORKSPACE_FILES_YIELD_EVERY_DIRECTORIES = 32;
const FILTER_HEARTBEAT_EVERY = 64;
const FILTER_BATCH_SIZE = 256;
const WORKSPACE_SEARCH_INPUT_SCHEMA = typedToolInputSchema<WorkspaceSearchInput>(
  z.strictObject({
    limit: z.int().min(1).max(100).default(12),
    query: z.string().min(1),
  }),
);
const CONTENT_SEARCH_INPUT_SCHEMA = typedToolInputSchema<ContentSearchInput>(
  z.strictObject({
    case_sensitive: z.boolean().default(false),
    is_regex: z.boolean().default(false),
    limit: z.int().min(1).max(100).default(20),
    path: z.string().default("."),
    pattern: z.string().min(1),
  }),
);
const WORKSPACE_FILES_INPUT_SCHEMA = typedToolInputSchema<WorkspaceFilesInput>(
  z.strictObject({
    limit: z.int().min(1).max(WORKSPACE_FILES_MAX_LIMIT).default(WORKSPACE_FILES_DEFAULT_LIMIT),
    offset: z.int().min(0).default(0),
  }),
);

export function createWorkspaceSearchTool(): ToolDefinition<WorkspaceSearchInput> {
  return {
    description:
      "Search the active workspace for matching file paths without reading file contents.",
    effect: "search",
    executionClass: "read-only",
    idempotent: true,
    inputSchema: WORKSPACE_SEARCH_INPUT_SCHEMA,
    name: "workspace-search",
    source: "built-in",
    capabilities(_input, context) {
      return [
        {
          group: "filesystem",
          action: "read",
          path: context.cwd,
        },
      ];
    },
    execute(input, context) {
      const matches = searchWorkspacePaths(context.cwd, input.query, {
        limit: input.limit,
        threadDir: context.threadPaths.threadDir,
      });
      return matches.length > 0 ? matches.join("\n") : "No matching workspace paths found.";
    },
    formatInput(input) {
      return input.limit === 12 ? input.query : `${input.query} (limit ${input.limit})`;
    },
    legacyAdapter: {
      adapt(input, context) {
        return normalizeWorkspaceSearchInput(input, context);
      },
    } satisfies ToolInputAdapter<WorkspaceSearchInput>,
    summarize(input, output) {
      const count =
        output === "No matching workspace paths found."
          ? 0
          : output.split("\n").filter(Boolean).length;
      return `Tool workspace-search looked for "${input.query}" and found ${count} matching path${count === 1 ? "" : "s"}.`;
    },
  };
}

export function createContentSearchTool(): ToolDefinition<ContentSearchInput> {
  return {
    description: "Search file contents in the active workspace using a text or regex pattern.",
    effect: "search",
    executionClass: "read-only",
    idempotent: true,
    inputSchema: CONTENT_SEARCH_INPUT_SCHEMA,
    name: "content-search",
    source: "built-in",
    capabilities(input, context) {
      validateContentSearchInput(input);
      const path = resolve(context.cwd, input.path.trim());
      return [
        {
          group: "filesystem",
          action: "read",
          path,
        },
      ];
    },
    async execute(input, context) {
      const result = await searchWorkspaceContent(context.cwd, input, {
        threadDir: context.threadPaths.threadDir,
      });
      for (const filePath of result.readPaths) {
        context.recordWorkspaceRead(filePath);
      }
      return formatContentSearchOutput(result);
    },
    formatInput(input) {
      const qualifiers = [
        input.path === "." ? undefined : input.path,
        input.is_regex ? "regex" : undefined,
        input.case_sensitive ? "case-sensitive" : undefined,
        input.limit === 20 ? undefined : `limit ${input.limit}`,
      ].filter((value): value is string => Boolean(value));
      return qualifiers.length === 0
        ? input.pattern
        : `${input.pattern} (${qualifiers.join(", ")})`;
    },
    legacyAdapter: {
      adapt(input, context) {
        return normalizeContentSearchInput(input, context);
      },
    } satisfies ToolInputAdapter<ContentSearchInput>,
    summarize(input, output) {
      const count = extractContentSearchMatchCount(output);
      return `Tool content-search looked for "${input.pattern}" and found ${count} matching line${count === 1 ? "" : "s"}.`;
    },
  };
}

function validateContentSearchInput(input: ContentSearchInput): void {
  if (!input.is_regex) {
    return;
  }
  try {
    new RegExp(input.pattern, input.case_sensitive ? "" : "i");
  } catch (error) {
    throw new BestAgentError(
      "TOOL_INPUT_INVALID",
      error instanceof Error
        ? `content-search regex is invalid: ${error.message}`
        : "content-search regex is invalid.",
    );
  }
}

export function createWorkspaceFilesTool(): ToolDefinition<WorkspaceFilesInput> {
  return {
    description:
      "List workspace files in lexicographic order. In git repositories this includes tracked and untracked non-ignored files and supports pagination for long inspections.",
    effect: "search",
    executionClass: "read-only",
    heartbeatPolicy: "required",
    idempotent: true,
    inputSchema: WORKSPACE_FILES_INPUT_SCHEMA,
    name: "workspace-files",
    source: "built-in",
    capabilities(_input, context) {
      return [
        {
          group: "filesystem",
          action: "read",
          path: context.cwd,
          roots: [context.cwd],
        },
      ];
    },
    async execute(input, context) {
      const files = await listWorkspaceFiles(context.cwd, context);
      const page = files.slice(input.offset, input.offset + input.limit);
      return [
        `TOTAL | ${files.length}`,
        `OFFSET | ${input.offset}`,
        `LIMIT | ${input.limit}`,
        `RETURNED | ${page.length}`,
        ...page.map((path) => `PATH | ${path}`),
      ].join("\n");
    },
    formatInput(input) {
      return `${input.offset}:${input.limit}`;
    },
    legacyAdapter: {
      adapt(input) {
        return normalizeWorkspaceFilesInput(input);
      },
    } satisfies ToolInputAdapter<WorkspaceFilesInput>,
    summarize(input, output) {
      const returned = Number(output.match(/^RETURNED \| (\d+)$/m)?.[1] ?? "0");
      const total = Number(output.match(/^TOTAL \| (\d+)$/m)?.[1] ?? "0");
      return `Tool workspace-files listed ${returned} of ${total} workspace files starting at offset ${input.offset}.`;
    },
  };
}

// ── searchWorkspacePaths (exported for agent-runtime) ───────────────────

export function searchWorkspacePaths(
  cwd: string,
  query: string,
  options: { limit?: number; threadDir?: string } = {},
): string[] {
  const normalizedQuery = query.trim().toLowerCase();
  if (!normalizedQuery) {
    return [];
  }

  const limit = Math.max(1, Math.min(options.limit ?? 12, 50));
  const storageRoot = options.threadDir ? dirname(dirname(options.threadDir)) : undefined;
  const ignoredRoots = [storageRoot].filter((value): value is string => Boolean(value));
  const matches: Array<{ path: string; score: number }> = [];
  const tokens = normalizedQuery.split(/[^a-z0-9]+/).filter(Boolean);
  const gitFiles = listGitSearchFilesSync(cwd);
  if (gitFiles) {
    for (const relativePath of gitFiles) {
      const score = scoreWorkspacePathMatch(relativePath, normalizedQuery, tokens);
      if (score !== undefined) {
        matches.push({ path: relativePath, score });
      }
    }
    return formatWorkspacePathMatches(matches, limit);
  }

  const walk = (directory: string): void => {
    if (matches.length >= limit * 6) {
      return;
    }
    for (const ignoredRoot of ignoredRoots) {
      if (directory === ignoredRoot || directory.startsWith(`${ignoredRoot}/`)) {
        return;
      }
    }

    const entries = safeReadDir(directory);
    for (const entry of entries) {
      const absolutePath = resolve(directory, entry.name);
      if (entry.isDirectory()) {
        if (WORKSPACE_FILES_IGNORED_DIRECTORIES.has(entry.name)) {
          continue;
        }
        walk(absolutePath);
        continue;
      }
      if (!entry.isFile()) {
        continue;
      }

      const relativePath = relative(cwd, absolutePath).replace(/\\/g, "/");
      const score = scoreWorkspacePathMatch(relativePath, normalizedQuery, tokens);
      if (score === undefined) {
        continue;
      }
      matches.push({ path: relativePath, score });
    }
  };

  walk(cwd);

  return formatWorkspacePathMatches(matches, limit);
}

function formatWorkspacePathMatches(
  matches: Array<{ path: string; score: number }>,
  limit: number,
): string[] {
  return matches
    .sort((left, right) => left.score - right.score || left.path.localeCompare(right.path))
    .slice(0, limit)
    .map((entry) => entry.path);
}

function listGitSearchFilesSync(cwd: string): string[] | undefined {
  try {
    return execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
      cwd,
      encoding: "utf8",
      maxBuffer: 16 * 1024 * 1024,
      stdio: ["ignore", "pipe", "ignore"],
    })
      .split("\n")
      .map((path) => path.trim())
      .filter(Boolean);
  } catch {
    return undefined;
  }
}

// ── Content search ──────────────────────────────────────────────────────

type ContentSearchMatch = {
  lineContent: string;
  lineNumber: number;
  path: string;
};

type RankedContentSearchMatch = ContentSearchMatch & {
  score: number;
};

async function searchWorkspaceContent(
  cwd: string,
  input: ContentSearchInput,
  options: { threadDir?: string } = {},
): Promise<{ matches: ContentSearchMatch[]; readPaths: string[]; totalMatches: number }> {
  const searchRoot = resolve(cwd, input.path);
  const storageRoot = options.threadDir ? dirname(dirname(options.threadDir)) : undefined;
  const ignoredRoots = [storageRoot].filter((value): value is string => Boolean(value));
  const matcher = createContentSearchMatcher(input);
  const matches: RankedContentSearchMatch[] = [];
  const readPaths = new Set<string>();
  const explicitFileSearch = !safeIsDirectory(searchRoot);
  let totalMatches = 0;

  const searchFile = async (absolutePath: string): Promise<void> => {
    if (classifySensitivePath(absolutePath)) {
      return;
    }
    for (const ignoredRoot of ignoredRoots) {
      if (absolutePath === ignoredRoot || absolutePath.startsWith(`${ignoredRoot}/`)) {
        return;
      }
    }

    try {
      const maxBytes = explicitFileSearch
        ? CONTENT_SEARCH_MAX_EXPLICIT_FILE_BYTES
        : CONTENT_SEARCH_MAX_FILE_BYTES;
      if (statSync(absolutePath).size > maxBytes) {
        return;
      }
    } catch {
      return;
    }

    let buffer: Buffer;
    try {
      buffer = await readFile(absolutePath);
    } catch {
      return;
    }
    if (buffer.includes(0)) {
      return;
    }

    const content = buffer.toString("utf8");
    const lines = splitTextFileLines(content);
    const relativePath = relative(cwd, absolutePath).replace(/\\/g, "/");
    const score = scoreContentSearchPathMatch(relativePath, input);
    const fileMatches: RankedContentSearchMatch[] = [];
    for (let index = 0; index < lines.length; index += 1) {
      if (!matcher(lines[index])) {
        continue;
      }
      totalMatches += 1;
      fileMatches.push({
        lineContent:
          lines[index].length > CONTENT_SEARCH_LINE_PREVIEW_CHARS
            ? lines[index].slice(0, CONTENT_SEARCH_LINE_PREVIEW_CHARS)
            : lines[index],
        lineNumber: index + 1,
        path: relativePath,
        score: score + scoreContentSearchLine(lines[index]),
      });
    }
    fileMatches.sort(compareContentSearchMatches);
    for (let index = 0; index < fileMatches.length; index += 1) {
      const match = fileMatches[index] as RankedContentSearchMatch;
      recordContentSearchMatch(matches, input.limit, {
        ...match,
        score: match.score + index * 100,
      });
    }
    if (fileMatches.length > 0) {
      readPaths.add(absolutePath);
    }
  };

  const walk = async (directory: string): Promise<void> => {
    for (const ignoredRoot of ignoredRoots) {
      if (directory === ignoredRoot || directory.startsWith(`${ignoredRoot}/`)) {
        return;
      }
    }

    const entries = safeReadDir(directory);
    for (const entry of entries) {
      const absolutePath = resolve(directory, entry.name);
      if (entry.isDirectory()) {
        if (WORKSPACE_FILES_IGNORED_DIRECTORIES.has(entry.name)) {
          continue;
        }
        await walk(absolutePath);
        continue;
      }
      if (!entry.isFile()) {
        continue;
      }
      await searchFile(absolutePath);
    }
  };

  if (safeIsDirectory(searchRoot)) {
    const gitFiles = input.path === "." ? await listGitSearchFiles(cwd) : undefined;
    if (gitFiles) {
      for (const relativePath of gitFiles) {
        await searchFile(resolve(cwd, relativePath));
      }
    } else {
      await walk(searchRoot);
    }
  } else {
    await searchFile(searchRoot);
  }

  return {
    matches: matches.sort(compareContentSearchMatches).map((match) => ({
      lineContent: match.lineContent,
      lineNumber: match.lineNumber,
      path: match.path,
    })),
    readPaths: Array.from(readPaths),
    totalMatches,
  };
}

function recordContentSearchMatch(
  matches: RankedContentSearchMatch[],
  limit: number,
  match: RankedContentSearchMatch,
): void {
  if (matches.length < limit) {
    matches.push(match);
    return;
  }

  let worstIndex = 0;
  for (let index = 1; index < matches.length; index += 1) {
    if (
      compareContentSearchMatches(matches[worstIndex] as RankedContentSearchMatch, matches[index]) <
      0
    ) {
      worstIndex = index;
    }
  }
  if (compareContentSearchMatches(match, matches[worstIndex] as RankedContentSearchMatch) < 0) {
    matches[worstIndex] = match;
  }
}

function compareContentSearchMatches(
  left: RankedContentSearchMatch,
  right: RankedContentSearchMatch,
): number {
  return (
    left.score - right.score ||
    left.path.localeCompare(right.path) ||
    left.lineNumber - right.lineNumber
  );
}

function scoreContentSearchPathMatch(relativePath: string, input: ContentSearchInput): number {
  const normalizedPattern = input.pattern.trim().toLowerCase();
  const score = scoreWorkspacePathMatch(
    relativePath,
    normalizedPattern,
    normalizedPattern.split(/[^a-z0-9]+/).filter(Boolean),
  );
  return score ?? 10_000;
}

function scoreContentSearchLine(line: string): number {
  const trimmed = line.trim();
  let score = 0;
  if (/^#{1,6}\s/u.test(trimmed)) {
    score += 80;
  }
  if (looksLikeCommandLine(trimmed)) {
    score -= 160;
  }
  if (/--[a-z0-9][a-z0-9-]*/iu.test(trimmed)) {
    score -= 40;
  }
  return score;
}

function looksLikeCommandLine(line: string): boolean {
  return /^(?:\$\s*)?(?:pnpm|npm|yarn|node|python3?|uv|cargo|go|make|bash|sh|bun|deno|npx)\b/u.test(
    line,
  );
}

async function listGitSearchFiles(cwd: string): Promise<string[] | undefined> {
  try {
    const { stdout } = await execFileAsync(
      "git",
      ["ls-files", "--cached", "--others", "--exclude-standard"],
      {
        cwd,
        encoding: "utf8",
        maxBuffer: 16 * 1024 * 1024,
      },
    );
    return stdout
      .split("\n")
      .map((path) => path.trim())
      .filter(Boolean);
  } catch {
    return undefined;
  }
}

function yieldToEventLoop(): Promise<void> {
  return new Promise((resolve) => setImmediate(resolve));
}

async function listWorkspaceFiles(cwd: string, context: ToolExecutionContext): Promise<string[]> {
  context.heartbeat();
  try {
    const output = await runGitLsFiles(cwd, context);
    const lines = output.split("\n");
    const files: string[] = [];
    for (let i = 0; i < lines.length; i++) {
      if (i > 0 && i % FILTER_HEARTBEAT_EVERY === 0) {
        context.heartbeat();
      }
      if (i > 0 && i % FILTER_BATCH_SIZE === 0) {
        await yieldToEventLoop();
      }
      const path = lines[i].trim();
      if (isReadableWorkspaceFile(cwd, path, context)) {
        files.push(path);
      }
    }
    context.heartbeat();
    files.sort((left, right) => left.localeCompare(right));
    context.heartbeat();
    return files;
  } catch {
    return collectWorkspaceFilesWithHeartbeat(cwd, context);
  }
}

function isReadableWorkspaceFile(
  cwd: string,
  path: string,
  context: ToolExecutionContext,
): boolean {
  if (!path) return false;
  const absolutePath = resolve(cwd, path);
  return context.sandbox.exists(absolutePath) && !context.sandbox.isDirectory(absolutePath);
}

async function runGitLsFiles(cwd: string, context: ToolExecutionContext): Promise<string> {
  let heartbeatTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
    context.heartbeat();
  }, WORKSPACE_FILES_HEARTBEAT_INTERVAL_MS);
  heartbeatTimer.unref?.();
  try {
    const { stdout } = await execFileAsync(
      "git",
      ["ls-files", "--cached", "--others", "--exclude-standard"],
      {
        cwd,
        encoding: "utf8",
        maxBuffer: 16 * 1024 * 1024,
      },
    );
    return stdout;
  } finally {
    if (heartbeatTimer) {
      clearInterval(heartbeatTimer);
      heartbeatTimer = null;
    }
  }
}

async function collectWorkspaceFiles(
  cwd: string,
  context: ToolExecutionContext,
  relativeDir = "",
  state: { visitedDirectories: number } = { visitedDirectories: 0 },
): Promise<string[]> {
  const directory = relativeDir ? resolve(cwd, relativeDir) : cwd;
  const entries = (await readdir(directory, { withFileTypes: true })).sort((left, right) =>
    left.name.localeCompare(right.name),
  );
  const files: string[] = [];
  state.visitedDirectories += 1;
  context.heartbeat();
  if (state.visitedDirectories % WORKSPACE_FILES_YIELD_EVERY_DIRECTORIES === 0) {
    await new Promise<void>((resolve) => {
      setImmediate(resolve);
    });
  }

  for (const entry of entries) {
    const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
    if (entry.isDirectory()) {
      if (WORKSPACE_FILES_IGNORED_DIRECTORIES.has(entry.name)) {
        continue;
      }
      files.push(...(await collectWorkspaceFiles(cwd, context, relativePath, state)));
      continue;
    }
    if (entry.isFile()) {
      files.push(relativePath);
    }
  }

  return files;
}

async function collectWorkspaceFilesWithHeartbeat(
  cwd: string,
  context: ToolExecutionContext,
): Promise<string[]> {
  let heartbeatTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
    context.heartbeat();
  }, WORKSPACE_FILES_HEARTBEAT_INTERVAL_MS);
  heartbeatTimer.unref?.();
  try {
    return await collectWorkspaceFiles(cwd, context);
  } finally {
    if (heartbeatTimer) {
      clearInterval(heartbeatTimer);
      heartbeatTimer = null;
    }
  }
}

// ── Normalize + format helpers ──────────────────────────────────────────

async function normalizeWorkspaceSearchInput(
  input: ToolInputValue,
  context: ToolExecutionContext,
): Promise<WorkspaceSearchInput> {
  const priorQuery = await inferRecentWorkspaceSearchQuery(context);
  const inferredInput = inferWorkspaceSearchInput(input, priorQuery);
  if (typeof input === "string") {
    const parsed = parseWorkspaceSearchText(input, priorQuery);
    if (!parsed?.query) {
      throw new BestAgentError(
        "TOOL_INPUT_INVALID",
        "workspace-search requires a non-empty query.",
      );
    }
    return {
      limit: normalizeWorkspaceSearchLimit(parsed.limit),
      query: parsed.query,
    };
  }
  if (!isPlainObject(input)) {
    if (inferredInput?.query) {
      return {
        limit: normalizeWorkspaceSearchLimit(inferredInput.limit),
        query: inferredInput.query,
      };
    }
    throw new BestAgentError(
      "TOOL_INPUT_INVALID",
      "workspace-search requires an object input with a query field.",
    );
  }

  const query =
    readWorkspaceSearchQueryField(input, ["query", "pattern", "path", "file", "name"]) ??
    inferredInput?.query;
  if (!query || query.trim().length === 0) {
    throw new BestAgentError("TOOL_INPUT_INVALID", "workspace-search requires a non-empty query.");
  }

  const limit = normalizeWorkspaceSearchLimit(
    typeof input.limit === "number" && Number.isInteger(input.limit)
      ? input.limit
      : inferredInput?.limit,
  );

  return {
    query: query.trim(),
    limit,
  };
}

function normalizeWorkspaceFilesInput(input: ToolInputValue): WorkspaceFilesInput {
  if (input === undefined || input === null || input === "") {
    return {
      limit: WORKSPACE_FILES_DEFAULT_LIMIT,
      offset: 0,
    };
  }
  if (!isPlainObject(input)) {
    throw new BestAgentError("TOOL_INPUT_INVALID", "workspace-files requires an object input.");
  }

  const rawLimit = readIntegerField(input, ["limit"]);
  const rawOffset = readIntegerField(input, ["offset", "start"]);
  return {
    limit:
      rawLimit === undefined
        ? WORKSPACE_FILES_DEFAULT_LIMIT
        : Math.min(WORKSPACE_FILES_MAX_LIMIT, Math.max(1, rawLimit)),
    offset: rawOffset === undefined ? 0 : Math.max(0, rawOffset),
  };
}

function normalizeContentSearchInput(
  input: ToolInputValue,
  context: ToolExecutionContext,
): ContentSearchInput {
  if (typeof input === "string") {
    const pattern = input.trim();
    if (!pattern) {
      throw new BestAgentError(
        "TOOL_INPUT_INVALID",
        "content-search requires a non-empty pattern.",
      );
    }
    return {
      case_sensitive: false,
      is_regex: false,
      limit: 20,
      path: ".",
      pattern,
    };
  }
  if (!isPlainObject(input)) {
    throw new BestAgentError(
      "TOOL_INPUT_INVALID",
      "content-search requires an object input with a pattern field.",
    );
  }

  const pattern = readContentSearchPattern(input);
  if (!pattern || pattern.trim().length === 0) {
    throw new BestAgentError("TOOL_INPUT_INVALID", "content-search requires a non-empty pattern.");
  }

  const path =
    readStringField(input, ["path", "file", "directory", "dir", "scope", "root"])?.trim() || ".";
  const resolvedPath = resolve(context.cwd, path);
  if (!context.sandbox.exists(resolvedPath)) {
    throw new BestAgentError(
      "TOOL_INPUT_NOT_FOUND",
      `Path ${resolvedPath} does not exist in the workspace.`,
    );
  }

  const is_regex = input.is_regex === true;
  if (is_regex) {
    try {
      new RegExp(pattern, input.case_sensitive === true ? "" : "i");
    } catch (error) {
      throw new BestAgentError(
        "TOOL_INPUT_INVALID",
        error instanceof Error
          ? `content-search regex is invalid: ${error.message}`
          : "content-search regex is invalid.",
      );
    }
  }

  const limitValue = input.limit;
  const limit =
    typeof limitValue === "number" && Number.isInteger(limitValue) && limitValue > 0
      ? Math.min(limitValue, 100)
      : 20;

  return {
    case_sensitive: input.case_sensitive === true,
    is_regex,
    limit,
    path,
    pattern: pattern.trim(),
  };
}

function readContentSearchPattern(input: Record<string, ToolInputValue>): string | undefined {
  const direct = readStringField(input, [
    "pattern",
    "query",
    "text",
    "search",
    "needle",
    "term",
    "keyword",
  ]);
  if (direct) {
    return direct;
  }

  for (const field of ["pattern", "query", "text", "search", "needle", "term", "keyword"]) {
    const value = input[field];
    if (!isPlainObject(value)) {
      continue;
    }
    const nested = readStringField(value, [
      "pattern",
      "query",
      "text",
      "search",
      "needle",
      "term",
      "keyword",
      "value",
      "content",
    ]);
    if (nested) {
      return nested;
    }
  }

  return undefined;
}

function formatContentSearchOutput(input: {
  matches: ContentSearchMatch[];
  totalMatches: number;
}): string {
  if (input.totalMatches === 0) {
    return "No matches found.";
  }

  const lines = input.matches.map(
    (match) => `${match.path}:${match.lineNumber}: ${truncateContentSearchLine(match.lineContent)}`,
  );
  lines.push(`(${input.totalMatches} total matches, showing first ${input.matches.length})`);
  return lines.join("\n");
}

function extractContentSearchMatchCount(output: string): number {
  if (output === "No matches found.") {
    return 0;
  }
  const summaryMatch = output.match(/\((\d+) total matches, showing first \d+\)$/m);
  if (summaryMatch) {
    return Number(summaryMatch[1]);
  }
  return output.split("\n").filter((line) => /^\S+:\d+: /.test(line)).length;
}

function truncateContentSearchLine(value: string): string {
  return truncateText(value, CONTENT_SEARCH_LINE_PREVIEW_CHARS);
}

// ── Path scoring ────────────────────────────────────────────────────────

function scoreWorkspacePathMatch(
  relativePath: string,
  query: string,
  tokens: string[],
): number | undefined {
  const normalizedPath = relativePath.toLowerCase();
  const fileName = basename(normalizedPath);

  if (!normalizedPath.includes(query) && !tokens.every((token) => normalizedPath.includes(token))) {
    return undefined;
  }

  let score = normalizedPath.length;
  if (fileName === query) {
    score -= 1000;
  } else if (fileName.startsWith(query)) {
    score -= 700;
  } else if (normalizedPath.includes(query)) {
    score -= 400;
  }

  score += relativePath.split("/").length * 5;
  const queryIndex = normalizedPath.indexOf(query);
  score += queryIndex >= 0 ? queryIndex : 100;
  return score;
}

// ── Shared filesystem helpers ───────────────────────────────────────────

function safeReadDir(directory: string): Dirent[] {
  try {
    return readdirSync(directory, { withFileTypes: true });
  } catch {
    return [];
  }
}

function safeIsDirectory(path: string): boolean {
  try {
    return statSync(path).isDirectory();
  } catch {
    return false;
  }
}

function createContentSearchMatcher(input: ContentSearchInput): (line: string) => boolean {
  if (input.is_regex) {
    const regex = new RegExp(input.pattern, input.case_sensitive ? "" : "i");
    return (line: string) => regex.test(line);
  }

  const pattern = input.case_sensitive ? input.pattern : input.pattern.toLowerCase();
  return (line: string) => {
    const haystack = input.case_sensitive ? line : line.toLowerCase();
    return haystack.includes(pattern);
  };
}

// ── Workspace search inference helpers ──────────────────────────────────

function readWorkspaceSearchQueryField(
  source: Record<string, ToolInputValue>,
  fields: string[],
): string | undefined {
  for (const field of fields) {
    const value = source[field];
    if (typeof value !== "string") {
      continue;
    }
    const parsed = parseWorkspaceSearchText(value);
    if (parsed?.query) {
      return parsed.query;
    }
  }
  return undefined;
}

function normalizeWorkspaceSearchLimit(limit: number | undefined): number {
  return typeof limit === "number" && Number.isInteger(limit) && limit > 0
    ? Math.min(limit, 50)
    : 12;
}

function parseWorkspaceSearchText(
  value: string,
  fallbackQuery?: string,
): Partial<WorkspaceSearchInput> | undefined {
  const normalized = stripProviderToolNoise(value);
  if (!normalized) {
    return fallbackQuery ? { query: fallbackQuery } : undefined;
  }

  const parsedJson = parseToolInputJsonValue(normalized);
  if (parsedJson !== undefined) {
    return inferWorkspaceSearchInput(parsedJson, fallbackQuery);
  }

  const limitMatch = normalized.match(/^(.*?)\s*\(limit\s*(\d+)\)\s*$/i);
  if (limitMatch) {
    const query = limitMatch[1]?.trim();
    return query
      ? {
          limit: Number(limitMatch[2]),
          query,
        }
      : fallbackQuery
        ? { limit: Number(limitMatch[2]), query: fallbackQuery }
        : undefined;
  }

  return { query: normalized };
}

function inferWorkspaceSearchInput(
  value: ToolInputValue,
  priorQuery?: string,
  depth = 0,
): Partial<WorkspaceSearchInput> | undefined {
  if (depth > 5 || value === null) {
    return undefined;
  }

  if (typeof value === "string") {
    return parseWorkspaceSearchText(value, priorQuery);
  }

  if (Array.isArray(value)) {
    let query: string | undefined;
    let limit: number | undefined;
    for (const entry of value) {
      if (query && limit !== undefined) {
        break;
      }
      if (typeof entry === "number" && Number.isInteger(entry) && entry > 0) {
        limit ??= entry;
        continue;
      }
      const inferred = inferWorkspaceSearchInput(entry, priorQuery, depth + 1);
      if (inferred?.query) {
        query ??= inferred.query;
      }
      if (inferred?.limit !== undefined) {
        limit ??= inferred.limit;
      }
    }
    if (!query && priorQuery) {
      query = priorQuery;
    }
    return query || limit !== undefined
      ? { ...(limit !== undefined ? { limit } : {}), ...(query ? { query } : {}) }
      : undefined;
  }

  if (!isPlainObject(value)) {
    return undefined;
  }

  let query = readWorkspaceSearchQueryField(value, [
    "query",
    "pattern",
    "path",
    "file",
    "name",
    "search",
    "value",
    "content",
    "text",
    "selection",
    "selectionText",
    "selection_text",
    "target",
    "location",
    "spec",
  ]);
  let limit =
    typeof value.limit === "number" && Number.isInteger(value.limit) ? value.limit : undefined;

  for (const entry of Object.values(value)) {
    if (query && limit !== undefined) {
      break;
    }
    const inferred = inferWorkspaceSearchInput(entry, query ?? priorQuery, depth + 1);
    if (inferred?.query) {
      query ??= inferred.query;
    }
    if (inferred?.limit !== undefined) {
      limit ??= inferred.limit;
    }
  }

  if (!query && priorQuery) {
    query = priorQuery;
  }
  return query || limit !== undefined
    ? { ...(limit !== undefined ? { limit } : {}), ...(query ? { query } : {}) }
    : undefined;
}

async function inferRecentWorkspaceSearchQuery(
  context: ToolExecutionContext,
): Promise<string | undefined> {
  const items = await context.store.listItems(context.threadId).catch(() => []);
  const sameTurnQuery = inferWorkspaceSearchQueryFromItems(items, context.turnId);
  if (sameTurnQuery) {
    return sameTurnQuery;
  }
  return inferWorkspaceSearchQueryFromItems(items);
}

function inferWorkspaceSearchQueryFromItems(
  items: Awaited<ReturnType<ThreadStore["listItems"]>>,
  turnId?: TurnId,
): string | undefined {
  for (let index = items.length - 1; index >= 0; index -= 1) {
    const item = items[index];
    if (turnId && item.turnId !== turnId) {
      continue;
    }
    if (item.kind !== "tool-call" || item.toolName !== "workspace-search") {
      continue;
    }
    const parsed = parseWorkspaceSearchText(item.input);
    if (parsed?.query) {
      return parsed.query;
    }
  }
  return undefined;
}
