src / tools / systemTools.ts

/**
 * @file systemTools.ts
 * System/misc tools: system info, clipboard, open file, preview HTML,
 * read document, analyze project, create visual treatment, notification, database.
 */

import { spawn } from "child_process";
import { writeFile, readdir, readFile, stat } from "fs/promises";
import * as os from "os";
import { join, resolve, dirname } from "path";
import { text, tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { validatePath, type ToolContext } from "./shared";

export function createSystemTools(
  ctx: ToolContext,
  config: { allowNotify: boolean; allowDb: boolean },
): Tool[] {
  const tools: Tool[] = [];

  // --- Read Document ---
  tools.push(tool({
    name: "read_document",
    description: "Read content from PDF or DOCX files.",
    parameters: { file_path: z.string() },
    implementation: async ({ file_path }) => {
      const fpath = validatePath(ctx.cwd, file_path);
      const ext = fpath.split('.').pop()?.toLowerCase();
      try {
        if (ext === 'pdf') {
          if (typeof global.DOMMatrix === 'undefined') {
            (global as any).DOMMatrix = class DOMMatrix {
              constructor(arg?: any) {
                (this as any).a = 1; (this as any).b = 0; (this as any).c = 0; (this as any).d = 1; (this as any).e = 0; (this as any).f = 0;
                if (Array.isArray(arg)) { (this as any).a = arg[0]; (this as any).b = arg[1]; (this as any).c = arg[2]; (this as any).d = arg[3]; (this as any).e = arg[4]; (this as any).f = arg[5]; }
              }
            };
          }
          const { PDFParse } = require("pdf-parse");
          const dataBuffer = await readFile(fpath);
          const parser = new PDFParse({ data: dataBuffer });
          const textResult = await parser.getText();
          const infoResult = await parser.getInfo();
          await parser.destroy();
          return { content: textResult.text, metadata: infoResult.info };
        } else if (ext === 'docx') {
          const mammoth = await import("mammoth");
          const result = await mammoth.extractRawText({ path: fpath });
          return { content: result.value, messages: result.messages };
        }
        return { error: "Unsupported document format. Use read_file for text files." };
      } catch (e) {
        return { error: `Failed to read document: ${e instanceof Error ? e.message : String(e)}` };
      }
    },
  }));

  // --- Analyze Project ---
  tools.push(tool({
    name: "analyze_project",
    description: "Run project-wide analysis (linting) to find errors and warnings.",
    parameters: {},
    implementation: async () => {
      let command = "", type = "unknown";
      try {
        const pkg = JSON.parse(await readFile(join(ctx.cwd, "package.json"), "utf-8"));
        if (pkg.scripts?.lint) { command = "npm run lint"; type = "npm-script"; }
        else if (pkg.devDependencies?.eslint || pkg.dependencies?.eslint) { command = "npx eslint . --format json"; type = "eslint"; }
      } catch {
        const entries = await readdir(ctx.cwd);
        if (entries.some(f => f.endsWith(".py"))) { command = "pylint ."; type = "python-lint"; }
      }
      if (!command) return { error: "Could not detect a supported linter (ESLint script or Python)." };
      try {
        const child = spawn(command, { shell: true, cwd: ctx.cwd, timeout: 60000 } as any);
        let stdout = "", stderr = "";
        child.stdout.on("data", (d: Buffer) => stdout += d);
        child.stderr.on("data", (d: Buffer) => stderr += d);
        await new Promise(resolve => child.on("close", resolve));
        return { tool: command, type, report: (stdout + stderr).substring(0, 3000) };
      } catch (e) {
        return { error: `Analysis failed: ${e instanceof Error ? e.message : String(e)}` };
      }
    },
  }));

  // --- Notification ---
  if (config.allowNotify) {
    tools.push(tool({
      name: "send_notification",
      description: "Send a system notification to the user.",
      parameters: { title: z.string(), message: z.string() },
      implementation: async ({ title, message }) => {
        const notifier = await import("node-notifier");
        notifier.notify({ title, message, sound: true, wait: false });
        return { success: true, message: "Notification sent." };
      },
    }));
  }

  // --- Database ---
  if (config.allowDb) {
    tools.push(tool({
      name: "query_database",
      description: "Execute a read-only query on a SQLite database file.",
      parameters: { db_path: z.string(), query: z.string() },
      implementation: async ({ db_path, query }) => {
        const fpath = validatePath(ctx.cwd, db_path);
        if (/^\s*(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE)\b/i.test(query)) return { error: "Only SELECT/read queries are allowed for safety." };
        try {
          const Database = (await import("better-sqlite3")).default;
          const db = new Database(fpath, { readonly: true });
          const results = db.prepare(query).all();
          db.close();
          return { results };
        } catch (e) {
          return { error: `Database query failed: ${e instanceof Error ? e.message : String(e)}` };
        }
      },
    }));
  }

  // --- System Info ---
  tools.push(tool({
    name: "get_system_info",
    description: "Get information about the system (OS, CPU, Memory).",
    parameters: {},
    implementation: async () => ({
      platform: os.platform(), arch: os.arch(), release: os.release(), hostname: os.hostname(),
      total_memory: os.totalmem(), free_memory: os.freemem(), cpus: os.cpus().length, node_version: process.version,
    }),
  }));

  // --- Clipboard ---
  tools.push(tool({
    name: "read_clipboard",
    description: "Read text content from the system clipboard.",
    parameters: {},
    implementation: async () => {
      let command = "", args: string[] = [];
      if (process.platform === "win32") { command = "powershell"; args = ["-command", "Get-Clipboard"]; }
      else if (process.platform === "darwin") { command = "pbpaste"; }
      else { command = "xclip"; args = ["-selection", "clipboard", "-o"]; }
      return Promise.race([
        new Promise(resolve => {
          const child = spawn(command, args);
          let output = "", error = "";
          child.stdout.on("data", d => output += d.toString());
          child.stderr.on("data", d => error += d.toString());
          child.on("close", code => resolve(code === 0 ? { content: output.trim() } : { error: `Failed. Code: ${code}. ${error}` }));
          child.on("error", err => resolve({ error: `Failed to spawn: ${err.message}` }));
        }),
        new Promise((_, rej) => setTimeout(() => rej(new Error("Clipboard timeout")), 5000)),
      ]).catch(err => ({ error: (err as Error).message }));
    },
  }));

  tools.push(tool({
    name: "write_clipboard",
    description: "Write text content to the system clipboard.",
    parameters: { content: z.string() },
    implementation: async ({ content }) => {
      let command = "", args: string[] = [], input = content;
      if (process.platform === "win32") {
        command = "powershell";
        const b64 = Buffer.from(content, 'utf8').toString('base64');
        args = ["-command", `$str = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')); Set-Clipboard -Value $str`];
        input = "";
      } else if (process.platform === "darwin") { command = "pbcopy"; }
      else { command = "xclip"; args = ["-selection", "clipboard", "-i"]; }
      return Promise.race([
        new Promise(resolve => {
          const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] });
          if (input && process.platform !== "win32") { child.stdin.write(input); child.stdin.end(); } else { child.stdin.end(); }
          let error = "";
          child.stderr.on("data", d => error += d.toString());
          child.on("close", code => resolve(code === 0 ? { success: true } : { error: `Failed. Code: ${code}. ${error}` }));
          child.on("error", err => resolve({ error: `Failed to spawn: ${err.message}` }));
        }),
        new Promise((_, rej) => setTimeout(() => rej(new Error("Clipboard timeout")), 5000)),
      ]).catch(err => ({ error: (err as Error).message }));
    },
  }));

  // --- Open File ---
  tools.push(tool({
    name: "open_file",
    description: "Open a file or URL in the system's default application.",
    parameters: { target: z.string().describe("File path or URL") },
    implementation: async ({ target }) => {
      let targetToOpen = target;
      if (!target.startsWith("http://") && !target.startsWith("https://")) targetToOpen = validatePath(ctx.cwd, target);
      let command = "", args: string[] = [];
      if (process.platform === "win32") { command = "cmd"; args = ["/c", "start", "", targetToOpen]; }
      else if (process.platform === "darwin") { command = "open"; args = [targetToOpen]; }
      else { command = "xdg-open"; args = [targetToOpen]; }
      const child = spawn(command, args, { stdio: 'ignore', detached: true });
      child.unref();
      return { success: true, message: `Opened ${targetToOpen}` };
    },
  }));

  // --- Preview HTML ---
  tools.push(tool({
    name: "preview_html",
    description: text`
      Preview an HTML file in the browser AND return a structural analysis.
      Can accept raw HTML content or a file path to an existing HTML file.
      Returns: section count, image references, duplicates, missing assets, and potential overflow warnings.
      Use this to verify your HTML output without asking the user to check manually.
    `,
    parameters: {
      html_content: z.string().optional().describe("Raw HTML content to preview. If omitted, file_name is required."),
      file_name: z.string().optional().describe("Path to an existing HTML file, or filename for new content."),
    },
    implementation: async ({ html_content, file_name }) => {
      let filePath: string;
      let html: string;

      if (!html_content && file_name) {
        // Read existing file
        filePath = resolve(ctx.cwd, file_name);
        try {
          html = await readFile(filePath, "utf-8");
        } catch {
          return { error: `File not found: ${filePath}` };
        }
      } else if (html_content) {
        const name = file_name || `preview_${Date.now()}.html`;
        filePath = validatePath(ctx.cwd, name);
        await writeFile(filePath, html_content, "utf-8");
        html = html_content;
      } else {
        return { error: "Provide either html_content or file_name" };
      }

      // Open in browser
      let command = "", args: string[] = [];
      if (process.platform === "win32") { command = "cmd"; args = ["/c", "start", "", filePath]; }
      else if (process.platform === "darwin") { command = "open"; args = [filePath]; }
      else { command = "xdg-open"; args = [filePath]; }
      const child = spawn(command, args, { stdio: 'ignore', detached: true });
      child.unref();

      // Structural analysis
      const analysis: Record<string, unknown> = {};

      // Count sections/containers
      const sectionMatches = html.match(/<(section|article|div[^>]*class[^>]*(?:slide|section|scene|page|hero))/gi);
      analysis.sections = sectionMatches ? sectionMatches.length : 0;

      // Extract all image references
      const imgRefs: string[] = [];
      const imgRegex = /(?:src|data-src|data-bg|background(?:-image)?)\s*[:=]\s*["']?(?:url\(["']?)?([^"');\s>]+\.(jpg|jpeg|png|gif|webp|svg|avif|bmp|tiff))/gi;
      let m;
      while ((m = imgRegex.exec(html)) !== null) {
        if (!/^(https?:|data:)/i.test(m[1])) imgRefs.push(m[1]);
      }

      // Detect duplicate images
      const imgCounts = new Map<string, number>();
      for (const ref of imgRefs) {
        imgCounts.set(ref, (imgCounts.get(ref) || 0) + 1);
      }
      const duplicateImgs = [...imgCounts.entries()].filter(([, c]) => c > 1).map(([f, c]) => `${f} (${c}x)`);

      analysis.total_images = imgRefs.length;
      analysis.unique_images = imgCounts.size;
      analysis.duplicate_images = duplicateImgs.length > 0 ? duplicateImgs : "none";

      // Check for missing image files
      const baseDir = dirname(filePath);
      const missingImgs: string[] = [];
      for (const ref of imgCounts.keys()) {
        try {
          await stat(resolve(baseDir, ref));
        } catch {
          missingImgs.push(ref);
        }
      }
      analysis.missing_images = missingImgs.length > 0 ? missingImgs : "none";

      // Text overflow risk — find text blocks > 500 chars inside elements
      const textBlocks = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "");
      const betweenTags = textBlocks.match(/>([^<]{500,})</g);
      analysis.long_text_blocks = betweenTags ? betweenTags.length : 0;
      if (betweenTags && betweenTags.length > 0) {
        analysis.overflow_warning = `${betweenTags.length} text block(s) exceed 500 chars — may overflow containers. Consider splitting into more sections.`;
      }

      // File size
      analysis.file_size_kb = Math.round(Buffer.byteLength(html, "utf-8") / 1024 * 10) / 10;

      return { success: true, path: filePath, message: "HTML preview launched in browser.", analysis };
    },
  }));

  // --- Create Visual Treatment ---
  tools.push(tool({
    name: "create_visual_treatment",
    description: "Generate a visual treatment HTML template with editorial design. Creates a self-contained HTML file with hero sections, image galleries, and cinematic typography.",
    parameters: {
      title: z.string().describe("Project title displayed as the main heading."),
      subtitle: z.string().optional().describe("Subtitle or tagline."),
      scenes: z.array(z.object({
        heading: z.string(), text: z.string(),
        images: z.array(z.string()).optional().describe("Image filenames for this scene."),
      })).describe("Array of scenes/sections to include."),
      output_file: z.string().optional().describe("Output filename. Default: treatment.html"),
      dark_mode: z.boolean().optional().describe("Use dark background theme. Default: true"),
    },
    implementation: async ({ title, subtitle, scenes, output_file, dark_mode }: {
      title: string; subtitle?: string; scenes: Array<{ heading: string; text: string; images?: string[] }>; output_file?: string; dark_mode?: boolean;
    }) => {
      const isDark = dark_mode !== false;
      const bg = isDark ? "#0a0a0a" : "#fafafa", fg = isDark ? "#f0f0f0" : "#1a1a1a";
      const mutedFg = isDark ? "#888" : "#666", accent = isDark ? "#e0c97f" : "#8b6914";
      const scenesHtml = scenes.map((scene, i) => {
        const imgsHtml = (scene.images || []).map(img => `        <img src="${img}" alt="${scene.heading}" loading="lazy">`).join("\n");
        const gallery = imgsHtml ? `\n      <div class="gallery">\n${imgsHtml}\n      </div>` : "";
        return `    <section class="scene" data-index="${i + 1}">\n      <span class="scene-number">${String(i + 1).padStart(2, "0")}</span>\n      <h2>${scene.heading}</h2>\n      <p>${scene.text}</p>${gallery}\n    </section>`;
      }).join("\n\n");
      const html = `<!DOCTYPE html>\n<html lang="pt-BR">\n<head>\n  <meta charset="UTF-8">\n  <meta name="viewport" content="width=device-width, initial-scale=1.0">\n  <title>${title}</title>\n  <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Inter:wght@300;400;600&display=swap" rel="stylesheet">\n  <style>\n    * { margin: 0; padding: 0; box-sizing: border-box; }\n    body { background: ${bg}; color: ${fg}; font-family: 'Inter', system-ui, sans-serif; font-weight: 300; line-height: 1.8; }\n    .hero { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 4rem 2rem; }\n    .hero h1 { font-family: 'Playfair Display', Georgia, serif; font-size: clamp(3rem, 8vw, 7rem); font-weight: 900; letter-spacing: -0.02em; line-height: 1.05; margin-bottom: 1rem; }\n    .hero .subtitle { font-size: clamp(1rem, 2vw, 1.5rem); color: ${mutedFg}; letter-spacing: 0.3em; text-transform: uppercase; font-weight: 300; }\n    .scene { padding: 6rem 2rem; max-width: 1200px; margin: 0 auto; opacity: 0; transform: translateY(40px); transition: opacity 0.8s ease, transform 0.8s ease; }\n    .scene.visible { opacity: 1; transform: translateY(0); }\n    .scene-number { font-family: 'Playfair Display', serif; font-size: 5rem; color: ${accent}; opacity: 0.3; display: block; margin-bottom: -1rem; }\n    .scene h2 { font-family: 'Playfair Display', serif; font-size: clamp(1.8rem, 4vw, 3rem); margin-bottom: 1.5rem; font-weight: 700; }\n    .scene p { font-size: 1.1rem; max-width: 65ch; color: ${mutedFg}; }\n    .gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-top: 2rem; }\n    .gallery img { width: 100%; height: 350px; object-fit: cover; border-radius: 4px; transition: transform 0.4s ease, filter 0.4s ease; filter: ${isDark ? "brightness(0.85) contrast(1.1)" : "none"}; }\n    .gallery img:hover { transform: scale(1.03); filter: brightness(1); }\n    @media (max-width: 768px) { .scene { padding: 3rem 1.5rem; } .gallery { grid-template-columns: 1fr; } .gallery img { height: 250px; } }\n  </style>\n</head>\n<body>\n  <header class="hero">\n    <h1>${title}</h1>\n    ${subtitle ? `<p class="subtitle">${subtitle}</p>` : ""}\n  </header>\n\n${scenesHtml}\n\n  <script>\n    const observer = new IntersectionObserver((entries) => {\n      entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });\n    }, { threshold: 0.15 });\n    document.querySelectorAll('.scene').forEach(s => observer.observe(s));\n  </script>\n</body>\n</html>`;
      const fileName = output_file || "treatment.html";
      const filePath = validatePath(ctx.cwd, fileName);
      await writeFile(filePath, html, "utf-8");
      return { success: true, path: filePath, sections: scenes.length, message: `Visual treatment saved. Use preview_html or open_file to view it.` };
    },
  }));

  return tools;
}