// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited

import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import * as vscode from "vscode";
import { log } from "./output.js";

/**
 * Webview panel that renders the engine's interactive graph HTML inside
 * VS Code, so users don't have to bounce out to a browser tab.
 *
 * The engine's `codebase_graph_visualize` tool writes a self-contained
 * HTML file (vendored Cytoscape + Dagre, no external scripts) to
 * `os.tmpdir()/socraticode-graph/<projectId>.html`. We read the file
 * directly and inject it into a webview.
 *
 * Why this design:
 *
 * - The engine already produces a usable artefact. Re-implementing a
 *   native graph view would mean duplicating the pipeline, which we
 *   explicitly want to avoid.
 * - Reading from a known temp path avoids any IPC dance with the running
 *   MCP server and works whether the user generated the graph from chat,
 *   from the command palette, or from a CLI invocation of the engine.
 * - A webview is sandboxed: scripts the engine writes can't reach the
 *   workspace. The CSP we set only allows inline scripts (the vendored
 *   Cytoscape) and webview-relative resources.
 */

const GRAPH_DIR = path.join(os.tmpdir(), "socraticode-graph");

let currentPanel: vscode.WebviewPanel | undefined;

export async function openInteractiveGraph(projectId?: string): Promise<void> {
  const html = await loadGraphHtml(projectId);
  if (!html) return;

  if (currentPanel) {
    currentPanel.webview.html = wrapHtml(html, currentPanel.webview);
    currentPanel.reveal();
    return;
  }

  currentPanel = vscode.window.createWebviewPanel(
    "socraticode.graph",
    "SocratiCode: interactive graph",
    vscode.ViewColumn.Active,
    {
      enableScripts: true,
      retainContextWhenHidden: true,
      localResourceRoots: [vscode.Uri.file(GRAPH_DIR)],
    },
  );
  currentPanel.iconPath = vscode.Uri.file(path.join(__dirname, "..", "images", "icon.png"));
  currentPanel.onDidDispose(() => {
    currentPanel = undefined;
  });
  currentPanel.webview.onDidReceiveMessage((msg: unknown) => {
    void handleWebviewMessage(msg);
  });
  currentPanel.webview.html = wrapHtml(html, currentPanel.webview);
}

async function loadGraphHtml(projectId?: string): Promise<string | undefined> {
  let target: string | undefined;
  if (projectId) {
    // The `socraticode.openInteractiveGraph` command accepts a projectId
    // argument from any caller (palette, sidebar, other extensions). A
    // value like `../../etc/passwd` would escape GRAPH_DIR via path.join.
    // Resolve and confirm the result stays inside GRAPH_DIR.
    const candidate = path.resolve(GRAPH_DIR, `${projectId}.html`);
    const dirResolved = path.resolve(GRAPH_DIR);
    if (candidate !== dirResolved && !candidate.startsWith(dirResolved + path.sep)) {
      log(`Graph panel: rejecting suspicious projectId: ${projectId}`);
      return undefined;
    }
    target = candidate;
  } else {
    // Pick the most recently modified graph file.
    try {
      const entries = await fs.readdir(GRAPH_DIR, { withFileTypes: true });
      let latestPath: string | undefined;
      let latestMtime = 0;
      for (const entry of entries) {
        if (!entry.isFile() || !entry.name.endsWith(".html")) continue;
        const p = path.join(GRAPH_DIR, entry.name);
        const stat = await fs.stat(p);
        if (stat.mtimeMs > latestMtime) {
          latestMtime = stat.mtimeMs;
          latestPath = p;
        }
      }
      target = latestPath;
    } catch {
      // Directory doesn't exist; fall through to the "no graph yet" path.
    }
  }

  if (!target) {
    await offerToGenerate();
    return undefined;
  }

  try {
    return await fs.readFile(target, "utf-8");
  } catch (err) {
    const e = err as Error;
    log(`Graph panel: failed to read ${target}: ${e.message}`);
    if ((err as NodeJS.ErrnoException).code === "ENOENT") {
      await offerToGenerate();
      return undefined;
    }
    vscode.window.showErrorMessage(`SocratiCode: ${e.message}`);
    return undefined;
  }
}

async function offerToGenerate(): Promise<void> {
  const action = await vscode.window.showInformationMessage(
    'No interactive graph has been generated yet. Ask your AI assistant to call the SocratiCode tool `codebase_graph_visualize` with `mode="interactive"`, then re-run this command.',
    "Copy prompt to clipboard",
    "Open chat",
  );
  if (action === "Copy prompt to clipboard") {
    const prompt =
      'Please use SocratiCode to build an interactive graph of this project: call codebase_graph_visualize with mode="interactive".';
    await vscode.env.clipboard.writeText(prompt);
    vscode.window.showInformationMessage("SocratiCode: prompt copied.");
  } else if (action === "Open chat") {
    // Not all VS Code-compatible editors expose `workbench.action.chat.open`
    // (some Theia-based forks omit it). Fall back to a friendlier message
    // rather than letting the rejection bubble up unhandled.
    try {
      await vscode.commands.executeCommand("workbench.action.chat.open");
    } catch (err) {
      log(`Open chat failed: ${(err as Error).message}`);
      vscode.window.showInformationMessage(
        "SocratiCode: this editor does not expose a chat command. Open your AI assistant manually and paste the copied prompt.",
      );
    }
  }
}

/**
 * Wrap the engine HTML so it works inside a webview:
 *
 * 1. Inject a CSP that allows inline scripts/styles (the vendored
 *    Cytoscape) but blocks remote loads. The engine HTML is generated
 *    locally and self-contained, so this doesn't break anything.
 * 2. Replace any local `file://` references with `webview.asWebviewUri()`.
 *    Today the engine inlines all its assets, so this is a defensive
 *    no-op, but worth keeping for forward compatibility.
 * 3. Inject a small bridge script so node-click events in the graph can
 *    `postMessage` back to the extension and we can `vscode.commands.executeCommand`
 *    to open files.
 */
function wrapHtml(html: string, webview: vscode.Webview): string {
  const cspSource = webview.cspSource;
  const csp = [
    "default-src 'none'",
    `img-src ${cspSource} data: blob:`,
    `style-src ${cspSource} 'unsafe-inline'`,
    `script-src ${cspSource} 'unsafe-inline'`,
    `font-src ${cspSource} data:`,
    "connect-src 'none'",
  ].join("; ");

  // The engine template already includes <html>...</html>. We inject the
  // CSP meta tag right after <head>. If <head> is missing for some reason,
  // we fall back to prepending a minimal head.
  const cspMeta = `<meta http-equiv="Content-Security-Policy" content="${csp}">`;
  const bridge = `
    <script>
      (function() {
        const vscode = acquireVsCodeApi();
        window.addEventListener('socraticode:openFile', (e) => {
          vscode.postMessage({ type: 'openFile', path: e.detail?.path, line: e.detail?.line });
        });
      })();
    </script>
  `;

  if (html.includes("<head>")) {
    return html.replace("<head>", `<head>${cspMeta}${bridge}`);
  }
  // If the document has <html> but no <head>, splice a <head> in right
  // after the opening <html ...> tag. Wrapping in a fresh <html>...</html>
  // here would nest two <html> elements, which most browsers tolerate
  // but is invalid markup.
  if (/<html[^>]*>/i.test(html)) {
    return html.replace(/(<html[^>]*>)/i, `$1<head>${cspMeta}${bridge}</head>`);
  }
  return `<!DOCTYPE html><html><head>${cspMeta}${bridge}</head><body>${html}</body></html>`;
}

async function handleWebviewMessage(msg: unknown): Promise<void> {
  if (typeof msg !== "object" || msg === null) return;
  const m = msg as { type?: string; path?: string; line?: number };
  if (m.type !== "openFile" || typeof m.path !== "string") return;

  const ws = vscode.workspace.workspaceFolders?.[0];
  if (!ws) {
    vscode.window.showWarningMessage("SocratiCode: open a workspace to navigate from the graph.");
    return;
  }

  // The webview HTML is generated by us, but the boundary is still
  // untrusted: anything that crosses `postMessage` could be tampered
  // with. Reject absolute paths and any path that escapes the workspace
  // root (`..`). Normalise via posix paths so the same validation works
  // on Windows, macOS, and Linux.
  const normalised = path.posix.normalize(m.path.replace(/\\/g, "/"));
  const isAbsolute = normalised.startsWith("/") || /^[A-Za-z]:\//.test(normalised);
  const escapesRoot =
    normalised === ".." || normalised.startsWith("../") || normalised.includes("/../");
  if (isAbsolute || escapesRoot || normalised === "." || normalised === "") {
    log(`Graph panel: rejecting suspicious path from webview: ${m.path}`);
    vscode.window.showWarningMessage("SocratiCode: invalid file path from graph.");
    return;
  }

  const uri = vscode.Uri.joinPath(ws.uri, ...normalised.split("/"));

  // Open the document first, then clamp the requested line number against
  // the actual document length. A malformed message with a huge `m.line`
  // (e.g. Number.MAX_SAFE_INTEGER) would otherwise build a Range far past
  // the end of the file. We only check `m.line > 0` and `Number.isInteger`
  // here because the upper bound depends on what we just opened.
  let editor: vscode.TextEditor;
  try {
    editor = await vscode.window.showTextDocument(uri);
  } catch (err) {
    log(`Graph panel: failed to open ${normalised}: ${(err as Error).message}`);
    return;
  }

  if (typeof m.line === "number" && Number.isInteger(m.line) && m.line > 0) {
    const lastLine = Math.max(0, editor.document.lineCount - 1);
    const lineIndex = Math.min(m.line - 1, lastLine);
    const range = new vscode.Range(lineIndex, 0, lineIndex, 0);
    editor.selection = new vscode.Selection(range.start, range.end);
    editor.revealRange(range);
  }
}
