import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { resolve, dirname, join } from "node:path";
import { homedir } from "node:os";

/* ── Mock realpathSync: acts as identity (no symlinks) by default ── */
const mockRealpathSync = vi.hoisted(() => vi.fn((p: string) => p));

vi.mock("node:fs", async (importOriginal) => {
  const original = await importOriginal<typeof import("node:fs")>();
  return {
    ...original,
    realpathSync: mockRealpathSync,
  };
});

import { getAllowedRoots, isPathAllowed, validateFsAccess } from "./path-validator.js";
import { _resetConfigForTesting } from "../config.js";

describe("getAllowedRoots", () => {
  let savedAllowedPaths: string | undefined;

  beforeEach(() => {
    savedAllowedPaths = process.env.ZENCODE_ALLOWED_PATHS;
    delete process.env.ZENCODE_ALLOWED_PATHS;
    _resetConfigForTesting();
  });

  afterEach(() => {
    if (savedAllowedPaths !== undefined) {
      process.env.ZENCODE_ALLOWED_PATHS = savedAllowedPaths;
    } else {
      delete process.env.ZENCODE_ALLOWED_PATHS;
    }
  });

  it("always includes homedir and cwd", () => {
    const roots = getAllowedRoots();
    expect(roots).toContain(resolve(homedir()));
    expect(roots).toContain(resolve(process.cwd()));
  });

  it("includes paths from ZENCODE_ALLOWED_PATHS env var", () => {
    process.env.ZENCODE_ALLOWED_PATHS = "/mnt/data,/opt/projects";
    _resetConfigForTesting();
    const roots = getAllowedRoots();
    expect(roots).toContain(resolve("/mnt/data"));
    expect(roots).toContain(resolve("/opt/projects"));
  });

  it("trims whitespace from env var entries", () => {
    process.env.ZENCODE_ALLOWED_PATHS = " /mnt/data , /opt/projects ";
    _resetConfigForTesting();
    const roots = getAllowedRoots();
    expect(roots).toContain(resolve("/mnt/data"));
    expect(roots).toContain(resolve("/opt/projects"));
  });

  it("ignores empty entries in env var", () => {
    process.env.ZENCODE_ALLOWED_PATHS = "/mnt/data,,/opt/projects,";
    _resetConfigForTesting();
    const roots = getAllowedRoots();
    // Should not throw and should include the non-empty paths
    expect(roots).toContain(resolve("/mnt/data"));
    expect(roots).toContain(resolve("/opt/projects"));
  });

  it("includes session cwds from callback", () => {
    const cwds = ["/workspace/project-a", "/workspace/project-b"];
    const roots = getAllowedRoots(() => cwds);
    expect(roots).toContain(resolve("/workspace/project-a"));
    expect(roots).toContain(resolve("/workspace/project-b"));
  });

  it("skips empty session cwds", () => {
    const roots = getAllowedRoots(() => ["", "/workspace/project"]);
    expect(roots).toContain(resolve("/workspace/project"));
    // Should not throw
  });

  it("deduplicates roots", () => {
    const home = homedir();
    const roots = getAllowedRoots(() => [home]);
    const homeResolved = resolve(home);
    const count = roots.filter((r) => r === homeResolved).length;
    expect(count).toBe(1);
  });
});

describe("isPathAllowed", () => {
  const allowedRoots = [resolve("/home/user"), resolve("/opt/projects")];

  it("allows path exactly matching a root", () => {
    expect(isPathAllowed(resolve("/home/user"), allowedRoots)).toBe(true);
  });

  it("allows child of an allowed root", () => {
    expect(isPathAllowed(resolve("/home/user/documents/file.txt"), allowedRoots)).toBe(true);
  });

  it("allows deeply nested child paths", () => {
    expect(isPathAllowed(resolve("/opt/projects/web/src/index.ts"), allowedRoots)).toBe(true);
  });

  it("rejects path outside all allowed roots", () => {
    expect(isPathAllowed(resolve("/etc/passwd"), allowedRoots)).toBe(false);
  });

  it("rejects path that shares a prefix but is not a child", () => {
    // "/home/username" should not be allowed just because "/home/user" is
    expect(isPathAllowed(resolve("/home/username"), allowedRoots)).toBe(false);
  });

  it("handles traversal via ../ after resolve", () => {
    // resolve normalizes out ../ — ensure "/home/user/../user2" → "/home/user2" is rejected
    const traversed = resolve("/home/user/../user2/secret");
    expect(isPathAllowed(traversed, allowedRoots)).toBe(false);
  });

  it("allows traversal that resolves back within an allowed root", () => {
    // "/home/user/docs/../downloads" → "/home/user/downloads" — still under /home/user
    const traversed = resolve("/home/user/docs/../downloads");
    expect(isPathAllowed(traversed, allowedRoots)).toBe(true);
  });
});

describe("validateFsAccess", () => {
  const allowedRoots = [resolve("/home/user"), resolve("/opt/projects")];

  beforeEach(() => {
    mockRealpathSync.mockImplementation((p: string) => p);
  });

  it("returns ok with resolved path for allowed path", () => {
    const result = validateFsAccess("/home/user/file.txt", allowedRoots);
    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.resolved).toBe(resolve("/home/user/file.txt"));
    }
  });

  it("resolves relative paths against cwd", () => {
    const result = validateFsAccess("./file.txt", [resolve(process.cwd())]);
    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.resolved).toBe(resolve("./file.txt"));
    }
  });

  it("returns error for path outside allowed roots", () => {
    const result = validateFsAccess("/etc/shadow", allowedRoots);
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toContain("Access denied");
      expect(result.error).toContain("outside allowed directories");
    }
  });

  it("normalizes ../ before checking", () => {
    const result = validateFsAccess("/home/user/../../etc/passwd", allowedRoots);
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toContain("Access denied");
    }
  });

  it("allows ../ that stays within an allowed root", () => {
    const result = validateFsAccess("/home/user/a/../b/file.txt", allowedRoots);
    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.resolved).toBe(resolve("/home/user/b/file.txt"));
    }
  });

  it("rejects symlink inside allowed dir pointing outside it", () => {
    // Symlink at /home/user/sneaky-link resolves to /etc/secret via realpathSync
    const symlinkPath = resolve("/home/user/sneaky-link");
    const realTarget = resolve("/etc/secret");
    mockRealpathSync.mockImplementation((p: string) => {
      if (p === symlinkPath) return realTarget;
      return p;
    });
    const result = validateFsAccess("/home/user/sneaky-link", allowedRoots);
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toContain("Access denied");
      expect(result.error).toContain("outside allowed directories");
    }
  });

  it("rejects symlink child path that resolves outside allowed roots", () => {
    // /home/user/link/file.txt → realpathSync → /var/data/file.txt
    const symlinkChild = resolve("/home/user/link/file.txt");
    const realTarget = resolve("/var/data/file.txt");
    mockRealpathSync.mockImplementation((p: string) => {
      if (p === symlinkChild) return realTarget;
      return p;
    });
    const result = validateFsAccess("/home/user/link/file.txt", allowedRoots);
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toContain("Access denied");
    }
  });

  it("allows symlink that resolves within an allowed root", () => {
    // /home/user/link → realpathSync → /home/user/real-dir
    const symlinkPath = resolve("/home/user/link");
    const realTarget = resolve("/home/user/real-dir");
    mockRealpathSync.mockImplementation((p: string) => {
      if (p === symlinkPath) return realTarget;
      return p;
    });
    const result = validateFsAccess("/home/user/link", allowedRoots);
    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.resolved).toBe(realTarget);
    }
  });

  it("resolves parent directory for non-existent files (new file creation)", () => {
    // /home/user/newfile.txt doesn't exist, but parent /home/user does
    const newFile = resolve("/home/user/newfile.txt");
    const parentDir = dirname(newFile);
    mockRealpathSync.mockImplementation((p: string) => {
      if (p === newFile) throw new Error("ENOENT");
      return p; // parent exists
    });
    const result = validateFsAccess("/home/user/newfile.txt", allowedRoots);
    expect(result.ok).toBe(true);
    if (result.ok) {
      // Should be join(realParent, basename) = join(parentDir, "newfile.txt")
      expect(result.resolved).toBe(join(parentDir, "newfile.txt"));
    }
  });

  it("rejects new file when symlinked parent resolves outside allowed roots", () => {
    // /home/user/evil-dir/newfile.txt — evil-dir is a symlink to /etc/
    const newFile = resolve("/home/user/evil-dir/newfile.txt");
    const parentDir = resolve("/home/user/evil-dir");
    const realParent = resolve("/etc");
    mockRealpathSync.mockImplementation((p: string) => {
      if (p === newFile) throw new Error("ENOENT");
      if (p === parentDir) return realParent;
      return p;
    });
    const result = validateFsAccess("/home/user/evil-dir/newfile.txt", allowedRoots);
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toContain("Access denied");
    }
  });

  it("returns error when neither file nor parent directory exist", () => {
    // Both file and parent don't exist
    mockRealpathSync.mockImplementation(() => {
      throw new Error("ENOENT");
    });
    const result = validateFsAccess("/nonexistent/path/file.txt", allowedRoots);
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toContain("parent directory");
      expect(result.error).toContain("does not exist");
    }
  });
});
