// v1.13.17-cross-repo-reads: resolveGrantRoot decision tree.
//
// Sam's dispatch note (2026-05-22): "in the project-root resolver ancestor
// walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
// filesystem root — check on every iteration, not just final parent.
// Symlinked input must not be able to escape the whitelist during the
// walk." The symlink-escape-mid-walk test below pins that invariant —
// without the per-iteration whitelist check, this case would walk OUTSIDE
// the whitelist root and return a phantom grant.

import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { realpath } from 'node:fs/promises';
import { resolveGrantRoot } from '../grant_resolver.js';
import type { Sql } from '../../db.js';

let tmp: string;
let whitelist: string;
let project: string;
let fork: string;
let outside: string;

// Fake sql tag — returns the projects rows we want without touching a real
// database. The resolver only ever does a single SELECT, so a single-shot
// mock that returns the prepared rows on every invocation is enough.
function makeSql(rows: Array<{ path: string }>): Sql {
  const tag = ((..._args: unknown[]) => Promise.resolve(rows)) as unknown as Sql;
  return tag;
}

beforeAll(async () => {
  tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gr-')));
  whitelist = join(tmp, 'whitelist');
  project = join(whitelist, 'boocode');
  fork = join(whitelist, 'forks', 'codecontext');
  outside = join(tmp, 'outside');
  await mkdir(project, { recursive: true });
  await mkdir(fork, { recursive: true });
  await mkdir(outside, { recursive: true });
  // Mark project as a repo (.git directory).
  await mkdir(join(project, '.git'));
  await writeFile(join(project, 'README.md'), 'project readme');
  // Mark fork as a repo via go.mod (matches the proposal's example).
  await writeFile(join(fork, 'go.mod'), 'module example.com/foo');
  await writeFile(join(fork, 'main.go'), 'package main');
  await writeFile(join(outside, 'secret.txt'), 'forbidden');
});

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

describe('resolveGrantRoot — happy paths', () => {
  it('refuses when the requested path is already under projectRoot', async () => {
    const result = await resolveGrantRoot(makeSql([]), join(project, 'README.md'), project, whitelist);
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.reason).toMatch(/already accessible/);
  });

  it('returns the project root when the path falls under a registered project', async () => {
    // Register `fork` as a known project. Resolver should return the project
    // ancestor (LONGEST match wins) rather than the repo-shape fallback.
    const result = await resolveGrantRoot(
      makeSql([{ path: fork }]),
      join(fork, 'main.go'),
      project,
      whitelist,
    );
    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.root).toBe(fork);
      expect(result.source).toBe('project');
    }
  });

  it('falls back to the nearest repo-shaped ancestor when no project matches', async () => {
    const result = await resolveGrantRoot(
      makeSql([]),
      join(fork, 'main.go'),
      project,
      whitelist,
    );
    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.root).toBe(fork);
      expect(result.source).toBe('whitelist');
    }
  });
});

describe('resolveGrantRoot — refusals', () => {
  it('refuses paths outside PROJECT_ROOT_WHITELIST', async () => {
    const result = await resolveGrantRoot(
      makeSql([]),
      join(outside, 'secret.txt'),
      project,
      whitelist,
    );
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
  });

  it('refuses non-absolute paths', async () => {
    const result = await resolveGrantRoot(makeSql([]), 'relative/path', project, whitelist);
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.reason).toMatch(/absolute/);
  });

  it('refuses missing paths without prompting', async () => {
    const result = await resolveGrantRoot(
      makeSql([]),
      join(whitelist, 'nope'),
      project,
      whitelist,
    );
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.reason).toMatch(/does not exist/);
  });

  it('refuses when no repo-shape marker is found before hitting the whitelist root', async () => {
    // Build a directory tree under the whitelist that has NO repo markers
    // all the way up to the whitelist root.
    const plain = join(whitelist, 'plain-dir', 'nested');
    await mkdir(plain, { recursive: true });
    await writeFile(join(plain, 'just-a-file.txt'), 'x');
    const result = await resolveGrantRoot(
      makeSql([]),
      join(plain, 'just-a-file.txt'),
      project,
      whitelist,
    );
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
  });

  it('does not grant the whitelist root itself as a fallback', async () => {
    // Even if .git existed at the whitelist root (it doesn't), we'd refuse.
    // Easier to assert: a path directly under whitelist with no repo marker.
    const direct = join(whitelist, 'lone-file.txt');
    await writeFile(direct, 'x');
    const result = await resolveGrantRoot(makeSql([]), direct, project, whitelist);
    expect(result.ok).toBe(false);
  });
});

describe('resolveGrantRoot — symlink-escape-mid-walk guard (Sam 2026-05-22)', () => {
  it('refuses a symlinked input whose realpath sits outside the whitelist', async () => {
    // The symlink lives nominally inside the whitelist, but its target
    // (realpath) is outside. The guard's first realpath() call normalizes
    // and the up-front whitelist check refuses immediately.
    const link = join(whitelist, 'escape-link');
    try {
      await symlink(outside, link);
      const result = await resolveGrantRoot(
        makeSql([]),
        join(link, 'secret.txt'),
        project,
        whitelist,
      );
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
    } finally {
      await rm(link, { force: true });
    }
  });

  it('walk loop terminates at the whitelist root, not at filesystem /', async () => {
    // Construct a deep tree with NO repo markers anywhere. Without a bound,
    // the walk would chase parents up to "/". The bound flips the loop into
    // a refusal once the cursor equals the realpath'd whitelist root.
    const deep = join(whitelist, 'a', 'b', 'c', 'd');
    await mkdir(deep, { recursive: true });
    await writeFile(join(deep, 'leaf.txt'), 'x');
    const result = await resolveGrantRoot(makeSql([]), join(deep, 'leaf.txt'), project, whitelist);
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
  });
});

describe('resolveGrantRoot — nearest-project disambiguation', () => {
  it('prefers the longest matching project path over a shorter ancestor', async () => {
    const outer = whitelist;
    const inner = fork; // /whitelist/forks/codecontext, deeper than outer
    const result = await resolveGrantRoot(
      makeSql([{ path: outer }, { path: inner }]),
      join(fork, 'main.go'),
      project,
      whitelist,
    );
    expect(result.ok).toBe(true);
    if (result.ok) expect(result.root).toBe(inner);
  });
});

// Belt-and-suspenders: silence a known dynamic-import warning that vitest
// occasionally emits on transient fs operations in CI but never in dev.
vi.spyOn(console, 'warn').mockImplementation(() => {});
