// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
//
// SPDX-License-Identifier: GPL-3.0-or-later

import { spawn, spawnSync } from "node:child_process";
import { createInterface } from "node:readline";
import { readFileSync, statSync } from "node:fs";
import { relative, basename } from "node:path";
import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js";
import {
  DEFAULT_MAX_BYTES,
  formatSize,
  GREP_MAX_LINE_LENGTH,
  truncateHead,
  truncateLine,
} from "../../util/truncate.js";
import { ToolInputError } from "../../util/errors.js";

const DEFAULT_LIMIT = 100;

const GrepSchema = Type.Object({
  pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
  path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })),
  glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts'" })),
  ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
  literal: Type.Optional(
    Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
  ),
  context: Type.Optional(Type.Number({ description: "Lines of context around each match (default: 0)" })),
  limit: Type.Optional(Type.Number({ description: "Maximum matches to return (default: 100)" })),
});

export const createGrepTool = (workspacePath: string): AgentTool => {
  const rgResult = spawnSync("which", ["rg"], { encoding: "utf-8" });
  if (rgResult.status !== 0) {
    throw new ToolInputError("grep requires ripgrep (rg) to be installed");
  }
  const rgPath = rgResult.stdout.trim();

  return {
    name: "grep",
    label: "Grep",
    description: `Search file contents for a pattern. Returns up to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB, whichever is reached first. Lines over ${GREP_MAX_LINE_LENGTH} chars are truncated. Respects .gitignore.`,
    parameters: GrepSchema as any,
    execute: async (_toolCallId: string, params: any) => {
      const searchDir: string | undefined = params.path;
      const searchPath = resolveToCwd(searchDir || ".", workspacePath);
      ensureWorkspacePath(workspacePath, searchPath);
      let isDirectory = false;
      try {
        isDirectory = statSync(searchPath).isDirectory();
      } catch {
        throw new ToolInputError(`Path does not exist or is not accessible: ${searchDir || "."}`);
      }

    const effectiveLimit: number = Math.max(1, Math.floor(params.limit ?? DEFAULT_LIMIT));
    const contextLines: number = Math.max(0, Math.floor(params.context ?? 0));

    const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
    if (params.ignoreCase) args.push("--ignore-case");
    if (params.literal) args.push("--fixed-strings");
    if (params.glob) args.push("--glob", params.glob);
    args.push(params.pattern, searchPath);

    return new Promise((resolve, reject) => {
      const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });

      const rl = createInterface({ input: child.stdout! });
      const fileCache = new Map<string, string[]>();

      interface MatchEntry {
        filePath: string;
        lineNumber: number;
      }

      const matches: MatchEntry[] = [];
      let stderrData = "";
      let limitReached = false;

      child.stderr!.on("data", (chunk: Buffer) => {
        stderrData += chunk.toString();
      });

      rl.on("line", (line: string) => {
        if (matches.length >= effectiveLimit) {
          if (!limitReached) {
            limitReached = true;
            child.kill();
          }
          return;
        }

        try {
          const parsed = JSON.parse(line);
          if (parsed.type === "match") {
            const filePath: string = parsed.data.path.text;
            const lineNumber: number = parsed.data.line_number;
            matches.push({ filePath, lineNumber });
          }
        } catch {
          // ignore malformed JSON lines
        }
      });

      child.on("close", (code: number | null) => {
        if (code !== 0 && code !== 1 && !limitReached) {
          reject(new Error(`rg exited with code ${code}: ${stderrData.trim()}`));
          return;
        }

        if (matches.length === 0) {
          resolve({
            content: [{ type: "text", text: "No matches found" }],
            details: { matches: 0 },
          });
          return;
        }

        const outputLines: string[] = [];
        let anyLineTruncated = false;

        for (const match of matches) {
          let lines: string[];
          if (fileCache.has(match.filePath)) {
            lines = fileCache.get(match.filePath)!;
          } else {
            try {
              lines = readFileSync(match.filePath, "utf-8").split(/\r?\n/);
              fileCache.set(match.filePath, lines);
            } catch {
              lines = [];
            }
          }

          const relPath = isDirectory
            ? relative(searchPath, match.filePath)
            : basename(match.filePath);

          const startLine = Math.max(0, match.lineNumber - 1 - contextLines);
          const endLine = Math.min(lines.length, match.lineNumber + contextLines);

          for (let i = startLine; i < endLine; i++) {
            const lineContent = lines[i] ?? "";
            const { text: truncatedContent, wasTruncated } = truncateLine(lineContent);
            if (wasTruncated) anyLineTruncated = true;

            const lineNum = i + 1;
            if (lineNum === match.lineNumber) {
              outputLines.push(`${relPath}:${lineNum}: ${truncatedContent}`);
            } else {
              outputLines.push(`${relPath}-${lineNum}- ${truncatedContent}`);
            }
          }
        }

        const rawOutput = outputLines.join("\n");
        const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });

        const notices: string[] = [];
        if (limitReached) {
          notices.push(
            `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
          );
        }
        if (truncation.truncated) {
          notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
        }
        if (anyLineTruncated) {
          notices.push(
            `Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`,
          );
        }

        let output = truncation.content;
        if (notices.length > 0) {
          output += `\n\n[${notices.join(". ")}]`;
        }

        resolve({
          content: [{ type: "text", text: output }],
          details: {
            matches: matches.length,
            ...(limitReached ? { matchLimitReached: effectiveLimit } : {}),
            ...(truncation.truncated ? { truncation } : {}),
            ...(anyLineTruncated ? { linesTruncated: true } : {}),
          },
        });
      });
    });
    },
  };
};
