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

import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { ensureWorkspacePath } from "../src/agent/tools/index.js";
import { expandPath, resolveToCwd, resolveReadPath } from "../src/agent/tools/path-utils.js";
import { writeWorkspaceFile } from "../src/workspace/content.js";

let workspace: string;

beforeAll(async () => {
	workspace = await mkdtemp(join(tmpdir(), "rumilo-test-"));
	await mkdir(join(workspace, "subdir"), { recursive: true });
	await writeFile(join(workspace, "hello.txt"), "hello");
	await writeFile(join(workspace, "subdir", "nested.txt"), "nested");
});

afterAll(async () => {
	await rm(workspace, { recursive: true, force: true });
});

// ─── ensureWorkspacePath ────────────────────────────────────────────

describe("ensureWorkspacePath", () => {
	test("allows workspace root itself", () => {
		const result = ensureWorkspacePath(workspace, ".");
		expect(result).toBe(workspace);
	});

	test("allows a relative child path", () => {
		const result = ensureWorkspacePath(workspace, "hello.txt");
		expect(result).toBe(join(workspace, "hello.txt"));
	});

	test("allows nested relative path", () => {
		const result = ensureWorkspacePath(workspace, "subdir/nested.txt");
		expect(result).toBe(join(workspace, "subdir", "nested.txt"));
	});

	test("rejects .. traversal escaping workspace", () => {
		expect(() => ensureWorkspacePath(workspace, "../../../etc/passwd")).toThrow("Path escapes workspace");
	});

	test("rejects absolute path outside workspace", () => {
		expect(() => ensureWorkspacePath(workspace, "/etc/passwd")).toThrow("Path escapes workspace");
	});

	test("allows absolute path inside workspace", () => {
		const absInside = join(workspace, "hello.txt");
		const result = ensureWorkspacePath(workspace, absInside);
		expect(result).toBe(absInside);
	});
});

// ─── expandPath: tilde must NOT escape workspace ────────────────────

describe("expandPath - tilde handling for workspace sandboxing", () => {
	test("tilde alone must not expand to homedir", () => {
		const result = expandPath("~");
		expect(result).toBe("~");
	});

	test("tilde-prefixed path must not expand to homedir", () => {
		const result = expandPath("~/secret");
		expect(result).not.toContain("/home");
		expect(result).not.toContain("/Users");
		expect(result).toBe("~/secret");
	});
});

// ─── resolveToCwd: must stay within workspace ───────────────────────

describe("resolveToCwd - workspace containment", () => {
	test("resolves relative path within workspace", () => {
		const result = resolveToCwd("hello.txt", workspace);
		expect(result).toBe(join(workspace, "hello.txt"));
	});

	test("resolves '.' to workspace root", () => {
		const result = resolveToCwd(".", workspace);
		expect(result).toBe(workspace);
	});
});

// ─── Tool-level containment (read tool) ─────────────────────────────

describe("read tool - workspace containment", () => {
	let readTool: any;

	beforeAll(async () => {
		const { createReadTool } = await import("../src/agent/tools/read.js");
		readTool = createReadTool(workspace);
	});

	test("reads file inside workspace", async () => {
		const result = await readTool.execute("id", { path: "hello.txt" });
		expect(result.content[0].text).toBe("hello");
	});

	test("rejects traversal via ..", async () => {
		await expect(readTool.execute("id", { path: "../../etc/passwd" })).rejects.toThrow(
			/escapes workspace/i,
		);
	});

	test("rejects absolute path outside workspace", async () => {
		await expect(readTool.execute("id", { path: "/etc/passwd" })).rejects.toThrow(
			/escapes workspace/i,
		);
	});

	test("tilde path stays within workspace (no homedir expansion)", async () => {
		await expect(readTool.execute("id", { path: "~/.bashrc" })).rejects.toThrow(/ENOENT/);
	});
});

// ─── Tool-level containment (ls tool) ───────────────────────────────

describe("ls tool - workspace containment", () => {
	let lsTool: any;

	beforeAll(async () => {
		const { createLsTool } = await import("../src/agent/tools/ls.js");
		lsTool = createLsTool(workspace);
	});

	test("lists workspace root", async () => {
		const result = await lsTool.execute("id", {});
		expect(result.content[0].text).toContain("hello.txt");
	});

	test("rejects traversal via ..", async () => {
		await expect(lsTool.execute("id", { path: "../../" })).rejects.toThrow(
			/escapes workspace/i,
		);
	});

	test("rejects absolute path outside workspace", async () => {
		await expect(lsTool.execute("id", { path: "/tmp" })).rejects.toThrow(
			/escapes workspace/i,
		);
	});
});

// ─── Tool-level containment (grep tool) ─────────────────────────────

describe("grep tool - workspace containment", () => {
	let grepTool: any;

	beforeAll(async () => {
		const { createGrepTool } = await import("../src/agent/tools/grep.js");
		grepTool = createGrepTool(workspace);
	});

	test("searches within workspace", async () => {
		const result = await grepTool.execute("id", { pattern: "hello", literal: true });
		expect(result.content[0].text).toContain("hello");
	});

	test("rejects traversal via ..", async () => {
		await expect(
			grepTool.execute("id", { pattern: "root", path: "../../etc" }),
		).rejects.toThrow(/escapes workspace/i);
	});

	test("rejects absolute path outside workspace", async () => {
		await expect(
			grepTool.execute("id", { pattern: "root", path: "/etc" }),
		).rejects.toThrow(/escapes workspace/i);
	});
});

// ─── Tool-level containment (find tool) ─────────────────────────────

describe("find tool - workspace containment", () => {
	let findTool: any;

	beforeAll(async () => {
		const { createFindTool } = await import("../src/agent/tools/find.js");
		findTool = createFindTool(workspace);
	});

	test("finds files in workspace", async () => {
		const result = await findTool.execute("id", { pattern: "*.txt" });
		expect(result.content[0].text).toContain("hello.txt");
	});

	test("rejects traversal via ..", async () => {
		await expect(
			findTool.execute("id", { pattern: "*", path: "../../" }),
		).rejects.toThrow(/escapes workspace/i);
	});

	test("rejects absolute path outside workspace", async () => {
		await expect(
			findTool.execute("id", { pattern: "*", path: "/tmp" }),
		).rejects.toThrow(/escapes workspace/i);
	});
});

// ─── writeWorkspaceFile containment (Issue #4) ──────────────────────

describe("writeWorkspaceFile - workspace containment", () => {
	test("writes file inside workspace", async () => {
		const result = await writeWorkspaceFile(workspace, "output.txt", "data");
		expect(result.filePath).toBe(join(workspace, "output.txt"));
	});

	test("writes nested file inside workspace", async () => {
		const result = await writeWorkspaceFile(workspace, "a/b/c.txt", "deep");
		expect(result.filePath).toBe(join(workspace, "a", "b", "c.txt"));
	});

	test("rejects traversal via ..", async () => {
		await expect(
			writeWorkspaceFile(workspace, "../../../tmp/evil.txt", "pwned"),
		).rejects.toThrow(/escapes workspace/i);
	});

	test("absolute path via join stays inside workspace", async () => {
		const result = await writeWorkspaceFile(workspace, "/tmp/evil.txt", "safe");
		expect(result.filePath).toBe(join(workspace, "tmp", "evil.txt"));
	});

	test("tilde path via join stays inside workspace", async () => {
		const result = await writeWorkspaceFile(workspace, "~/evil.txt", "safe");
		expect(result.filePath).toBe(join(workspace, "~", "evil.txt"));
	});
});

// ─── Symlink containment ────────────────────────────────────────────

describe("symlink containment", () => {
	let symlinkWorkspace: string;
	let outsideDir: string;

	beforeAll(async () => {
		symlinkWorkspace = await mkdtemp(join(tmpdir(), "rumilo-symlink-test-"));
		outsideDir = await mkdtemp(join(tmpdir(), "rumilo-outside-"));

		// Create a regular file inside workspace
		await writeFile(join(symlinkWorkspace, "legit.txt"), "safe content");

		// Create a subdirectory inside workspace
		await mkdir(join(symlinkWorkspace, "subdir"), { recursive: true });
		await writeFile(join(symlinkWorkspace, "subdir", "inner.txt"), "inner content");

		// Create a file outside workspace
		await writeFile(join(outsideDir, "secret.txt"), "secret content");

		// Symlink inside workspace pointing outside
		symlinkSync(outsideDir, join(symlinkWorkspace, "escape-link"));

		// Symlink inside workspace pointing to file outside
		symlinkSync(join(outsideDir, "secret.txt"), join(symlinkWorkspace, "secret-link.txt"));

		// Symlink inside workspace pointing to a file inside workspace (benign)
		symlinkSync(join(symlinkWorkspace, "legit.txt"), join(symlinkWorkspace, "good-link.txt"));

		// Nested symlink escape: subdir/nested-escape -> outsideDir
		symlinkSync(outsideDir, join(symlinkWorkspace, "subdir", "nested-escape"));
	});

	afterAll(async () => {
		await rm(symlinkWorkspace, { recursive: true, force: true });
		await rm(outsideDir, { recursive: true, force: true });
	});

	test("rejects symlink directory pointing outside workspace", () => {
		expect(() =>
			ensureWorkspacePath(symlinkWorkspace, "escape-link/secret.txt"),
		).toThrow(/escapes workspace via symlink/);
	});

	test("rejects symlink file pointing outside workspace", () => {
		expect(() =>
			ensureWorkspacePath(symlinkWorkspace, "secret-link.txt"),
		).toThrow(/escapes workspace via symlink/);
	});

	test("allows symlink pointing within workspace", () => {
		const result = ensureWorkspacePath(symlinkWorkspace, "good-link.txt");
		expect(result).toBe(join(symlinkWorkspace, "good-link.txt"));
	});

	test("rejects nested symlink escape (subdir/nested-escape)", () => {
		expect(() =>
			ensureWorkspacePath(symlinkWorkspace, "subdir/nested-escape/secret.txt"),
		).toThrow(/escapes workspace via symlink/);
	});

	test("rejects symlink escape via directory symlink alone", () => {
		expect(() =>
			ensureWorkspacePath(symlinkWorkspace, "escape-link"),
		).toThrow(/escapes workspace via symlink/);
	});

	test("handles non-existent file in real directory (write target)", () => {
		// File doesn't exist but parent is a real dir inside workspace — should pass
		const result = ensureWorkspacePath(symlinkWorkspace, "subdir/new-file.txt");
		expect(result).toBe(join(symlinkWorkspace, "subdir", "new-file.txt"));
	});

	test("rejects non-existent file under symlink-escaped parent", () => {
		// Parent is a symlink pointing outside — even though target file doesn't exist
		expect(() =>
			ensureWorkspacePath(symlinkWorkspace, "escape-link/new-file.txt"),
		).toThrow(/escapes workspace via symlink/);
	});

	test("writeWorkspaceFile rejects path through symlink escape", async () => {
		await expect(
			writeWorkspaceFile(symlinkWorkspace, "escape-link/evil.txt", "pwned"),
		).rejects.toThrow(/escapes workspace via symlink/);
	});

	test("writeWorkspaceFile allows normal nested write", async () => {
		const result = await writeWorkspaceFile(symlinkWorkspace, "new-dir/file.txt", "ok");
		expect(result.filePath).toBe(join(symlinkWorkspace, "new-dir", "file.txt"));
	});
});
