// Space-scoped task operations + per-space settings isolation (repository layer).
//
// These prove that the *space version* of a task (space_id set) behaves
// end-to-end at the repository layer for: delete, cancel, share/unshare,
// continue (child job carries space_id), visibility, feedback, title — and
// that per-space settings folders (AGENTS.md / memory / pieces) are isolated
// between case spaces and the personal space.
//
// The MCP registry (listEnabledForSpace) and SSH access (resolveAccess) are
// already exhaustively covered in src/mcp/registry.test.ts and
// src/ssh/access.test.ts; here we add the *folder* isolation (AGENTS.md /
// memory / pieces / skills all key on the resolved {rootDir, leafId}).

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { Repository, localTaskRepoName } from './repository.js';
import {
  readUserAgentsMd,
  writeUserAgentsMd,
  userPiecesDir,
  userMemoryDir,
} from '../user-folder/paths.js';

describe('space-scoped task operations (repository)', () => {
  let dir = '';
  let repo: Repository;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'space-task-ops-'));
    repo = new Repository(join(dir, `${randomUUID()}.db`));
  });

  afterEach(() => {
    repo.close();
    rmSync(dir, { recursive: true, force: true });
  });

  async function caseSpace(ownerId = 'owner-1', title = '案件A') {
    return repo.createSpace({ kind: 'case', title, ownerId });
  }

  // ── Delete ────────────────────────────────────────────────────────────────

  it('delete: removes a case-space task and its jobs, leaving sibling-space tasks intact', async () => {
    const spaceA = await caseSpace('owner-1', '案件A');
    const spaceB = await caseSpace('owner-1', '案件B');
    const taskA = await repo.createLocalTask({ title: 'a', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    const taskB = await repo.createLocalTask({ title: 'b', body: 'b', ownerId: 'owner-1', spaceId: spaceB.id });
    // Give task A a terminal job so we prove the job rows are deleted too.
    const jobA = await repo.createJob({
      repo: localTaskRepoName(taskA.id), issueNumber: taskA.id, instruction: 'go',
      ownerId: 'owner-1', spaceId: spaceA.id,
    });
    await repo.updateJob(jobA.id, { status: 'succeeded' });

    expect((await repo.getLocalTask(taskA.id))?.spaceId).toBe(spaceA.id);

    await repo.deleteLocalTask(taskA.id);

    // Task A is gone; its job row is gone; task B (other space) untouched.
    expect(await repo.getLocalTask(taskA.id)).toBeNull();
    expect(await repo.getJob(jobA.id)).toBeNull();
    const survivor = await repo.getLocalTask(taskB.id);
    expect(survivor).not.toBeNull();
    expect(survivor?.spaceId).toBe(spaceB.id);
  });

  it('delete: keeps the shared space files tree but cleans the conversation-specific runtime_dir', async () => {
    const spaceA = await caseSpace('owner-1', '案件A');
    // Two conversations in the SAME space share the persistent tree {dir}/space/{id}/files.
    const sharedTree = join(dir, 'space', spaceA.id, 'files');
    mkdirSync(join(sharedTree, 'output'), { recursive: true });
    writeFileSync(join(sharedTree, 'output', 'important.csv'), 'a,b\n1,2\n');

    const taskA = await repo.createLocalTask({ title: 'a', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    const taskB = await repo.createLocalTask({ title: 'b', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    // Per-conversation runtime dir (logs/checklists/raw) — space/{id}/runs/{taskId}.
    const runsA = join(dir, 'space', spaceA.id, 'runs', String(taskA.id));
    mkdirSync(runsA, { recursive: true });
    writeFileSync(join(runsA, 'activity.log'), 'log');
    await repo.updateLocalTask(taskA.id, { workspacePath: sharedTree, runtimeDir: runsA });
    await repo.updateLocalTask(taskB.id, { workspacePath: sharedTree });

    await repo.deleteLocalTask(taskA.id, dir);

    // Task A row gone; its conversation-specific runtime_dir is cleaned up...
    expect(await repo.getLocalTask(taskA.id)).toBeNull();
    expect(existsSync(runsA)).toBe(false);
    // ...but the SHARED tree + the other conversation's output survive.
    expect(await repo.getLocalTask(taskB.id)).not.toBeNull();
    expect(existsSync(sharedTree)).toBe(true);
    expect(existsSync(join(sharedTree, 'output', 'important.csv'))).toBe(true);
  });

  it('delete: keeps the shared tree even when workspace_path has a trailing /. (canonicalized)', async () => {
    const spaceA = await caseSpace('owner-1', '案件A');
    const sharedTree = join(dir, 'space', spaceA.id, 'files');
    mkdirSync(sharedTree, { recursive: true });
    writeFileSync(join(sharedTree, 'keep.txt'), 'k');
    const task = await repo.createLocalTask({ title: 'a', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    // A non-canonical but equivalent path must still be recognized as the shared tree.
    await repo.updateLocalTask(task.id, { workspacePath: sharedTree + '/.' });

    await repo.deleteLocalTask(task.id, dir);

    expect(existsSync(sharedTree)).toBe(true);
    expect(existsSync(join(sharedTree, 'keep.txt'))).toBe(true);
  });

  it('delete: a space/{id}/files tree is never whitelisted for deletion (only ephemeral/local per-task dirs are)', async () => {
    const spaceA = await caseSpace('owner-1', '案件A');
    const sharedTree = join(dir, 'space', spaceA.id, 'files');
    mkdirSync(sharedTree, { recursive: true });
    writeFileSync(join(sharedTree, 'keep.txt'), 'k');
    const task = await repo.createLocalTask({ title: 'a', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    await repo.updateLocalTask(task.id, { workspacePath: sharedTree });

    await repo.deleteLocalTask(task.id, dir);

    // The shared tree does not match the per-task whitelist (ephemeral/local), so it is kept.
    expect(existsSync(sharedTree)).toBe(true);
    expect(existsSync(join(sharedTree, 'keep.txt'))).toBe(true);
  });

  it('delete: does NOT delete an ephemeral/{taskId}-shaped path that lives OUTSIDE the worktree root', async () => {
    const spaceA = await caseSpace('owner-1', '案件A');
    // A path that has the per-task tail but is rooted elsewhere must NOT be deleted
    // (anchoring to worktreeDir blocks arbitrary `.../ephemeral/{taskId}` targets).
    const outsideRoot = mkdtempSync(join(tmpdir(), 'outside-root-'));
    const task = await repo.createLocalTask({ title: 'a', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    const outside = join(outsideRoot, 'ephemeral', String(task.id));
    mkdirSync(outside, { recursive: true });
    writeFileSync(join(outside, 'precious.txt'), 'do not delete');
    await repo.updateLocalTask(task.id, { workspacePath: outside });

    // worktreeDir = dir (the task's space root), NOT outsideRoot.
    await repo.deleteLocalTask(task.id, dir);

    expect(existsSync(outside)).toBe(true);
    expect(existsSync(join(outside, 'precious.txt'))).toBe(true);
    rmSync(outsideRoot, { recursive: true, force: true });
  });

  it('delete: protects the shared tree for a PERSONAL-space task whose space_id column is null', async () => {
    // Personal-space conversations are created with space_id=null on the row, yet
    // their workspace_path is the shared space/{personalId}/files tree. The shape
    // guard must protect it (and clean the per-conversation runs/) without space_id.
    const personalId = 'personal-space-xyz';
    const sharedTree = join(dir, 'space', personalId, 'files');
    mkdirSync(join(sharedTree, 'output'), { recursive: true });
    writeFileSync(join(sharedTree, 'output', 'mine.md'), '# mine');

    const task = await repo.createLocalTask({ title: 'p', body: 'b', ownerId: 'owner-1' }); // no spaceId
    expect((await repo.getLocalTask(task.id))?.spaceId ?? null).toBeNull();
    const runs = join(dir, 'space', personalId, 'runs', String(task.id));
    mkdirSync(runs, { recursive: true });
    writeFileSync(join(runs, 'activity.log'), 'log');
    await repo.updateLocalTask(task.id, { workspacePath: sharedTree, runtimeDir: runs });

    await repo.deleteLocalTask(task.id, dir);

    // Shared tree + output survive; the conversation-specific runs/ is cleaned.
    expect(existsSync(sharedTree)).toBe(true);
    expect(existsSync(join(sharedTree, 'output', 'mine.md'))).toBe(true);
    expect(existsSync(runs)).toBe(false);
  });

  it('delete: keeps the shared tree when `files` is a symlink (logical tail match, realpath tail differs)', async () => {
    const spaceA = await caseSpace('owner-1', '案件A');
    // The real shared data lives elsewhere; `space/{id}/files` is a symlink to it,
    // so realpath() tail is NOT "files". The logical-path check must still protect it.
    const realData = join(dir, 'shared-data');
    mkdirSync(realData, { recursive: true });
    writeFileSync(join(realData, 'keep.txt'), 'k');
    mkdirSync(join(dir, 'space', spaceA.id), { recursive: true });
    const logicalFiles = join(dir, 'space', spaceA.id, 'files');
    symlinkSync(realData, logicalFiles);

    const task = await repo.createLocalTask({ title: 'a', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    await repo.updateLocalTask(task.id, { workspacePath: logicalFiles });

    await repo.deleteLocalTask(task.id, dir);

    // The real shared data (reached via the symlink) must survive...
    expect(existsSync(realData)).toBe(true);
    expect(existsSync(join(realData, 'keep.txt'))).toBe(true);
    // ...and the symlink itself must remain so other conversations' workspace path still resolves.
    expect(existsSync(logicalFiles)).toBe(true);
  });

  it('delete: runtime_dir symlink pointing at the shared files tree is NOT followed into a delete', async () => {
    const spaceA = await caseSpace('owner-1', '案件A');
    const sharedFiles = join(dir, 'space', spaceA.id, 'files');
    mkdirSync(sharedFiles, { recursive: true });
    writeFileSync(join(sharedFiles, 'keep.txt'), 'k');
    // Malicious/buggy runtime_dir: shaped like runs/{taskId} but symlinked to the shared files.
    const task = await repo.createLocalTask({ title: 'a', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    mkdirSync(join(dir, 'space', spaceA.id, 'runs'), { recursive: true });
    const runtimeSymlink = join(dir, 'space', spaceA.id, 'runs', String(task.id));
    symlinkSync(sharedFiles, runtimeSymlink);
    // workspace_path is ephemeral so only the runtime_dir cleanup path is under test.
    await repo.updateLocalTask(task.id, {
      workspacePath: join(dir, 'ephemeral', String(task.id)),
      runtimeDir: runtimeSymlink,
    });

    await repo.deleteLocalTask(task.id, dir);

    // The shared files tree (the symlink target) must survive — cleanup must not follow it.
    expect(existsSync(sharedFiles)).toBe(true);
    expect(existsSync(join(sharedFiles, 'keep.txt'))).toBe(true);
  });

  it('delete: still removes a per-task ephemeral workspace', async () => {
    const spaceA = await caseSpace('owner-1', '案件A');
    const task = await repo.createLocalTask({ title: 'e', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    // ephemeral mode → per-task dir {dir}/ephemeral/{taskId} (not a shared space tree).
    const ephemeral = join(dir, 'ephemeral', String(task.id));
    mkdirSync(join(ephemeral, 'output'), { recursive: true });
    writeFileSync(join(ephemeral, 'output', 'scratch.txt'), 'x');
    await repo.updateLocalTask(task.id, { workspacePath: ephemeral });

    await repo.deleteLocalTask(task.id, dir);

    expect(await repo.getLocalTask(task.id)).toBeNull();
    expect(existsSync(ephemeral)).toBe(false);
  });

  it('delete: refuses a space task that has a running job', async () => {
    const spaceA = await caseSpace();
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    const job = await repo.createJob({
      repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
      ownerId: 'owner-1', spaceId: spaceA.id,
    });
    await repo.updateJob(job.id, { status: 'running' });

    await expect(repo.deleteLocalTask(task.id)).rejects.toThrow(/active job/);
    // Still present after the rejected delete.
    expect(await repo.getLocalTask(task.id)).not.toBeNull();
  });

  // ── Cancel ──────────────────────────────────────────────────────────────────

  it('cancel: flips the running job of a space task to cancelled (job keeps its space_id)', async () => {
    const spaceA = await caseSpace();
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });
    const job = await repo.createJob({
      repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
      ownerId: 'owner-1', spaceId: spaceA.id,
    });
    await repo.updateJob(job.id, { status: 'running' });

    const ok = repo.requestJobCancel(job.id);
    expect(ok).toBe(true);

    const after = await repo.getJob(job.id);
    expect(after?.status).toBe('cancelled');
    // Cancellation must not strip the space tag off the job.
    expect(after?.spaceId).toBe(spaceA.id);
  });

  // ── Share / unshare ───────────────────────────────────────────────────────

  it('share/unshare: a space task gets a token that resolves back to the same space; unshare clears it', async () => {
    const spaceA = await caseSpace();
    const task = await repo.createLocalTask({ title: 's', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });

    const token = await repo.shareLocalTask(task.id);
    expect(token).toBeTruthy();

    const viaToken = await repo.getLocalTaskByShareToken(token);
    expect(viaToken?.id).toBe(task.id);
    // The shared view still carries the space tag (share must not leak it out of its space).
    expect(viaToken?.spaceId).toBe(spaceA.id);

    await repo.unshareLocalTask(task.id);
    expect(await repo.getLocalTaskByShareToken(token)).toBeNull();
    expect((await repo.getLocalTask(task.id))?.shareToken).toBeNull();
  });

  // ── Continue-with-piece: space_id propagates to the child job ───────────────

  it('continue: a continuation job created for a space task carries the SAME space_id', async () => {
    const spaceA = await caseSpace();
    const task = await repo.createLocalTask({
      title: 't', body: 'b', pieceName: 'chat', ownerId: 'owner-1', spaceId: spaceA.id,
    });
    const prev = await repo.createJob({
      repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
      pieceName: 'chat', ownerId: 'owner-1', spaceId: spaceA.id,
    });
    await repo.updateJob(prev.id, { status: 'succeeded' });

    // Mirror the /continue route: read the task, build the child job from task.spaceId.
    const loaded = await repo.getLocalTask(task.id);
    const child = await repo.createJob({
      repo: localTaskRepoName(task.id),
      issueNumber: task.id,
      instruction: 'next step',
      pieceName: 'research',
      continuedFromJobId: prev.id,
      ownerId: loaded!.ownerId,
      spaceId: loaded!.spaceId ?? null,
    });

    expect(child.continuedFromJobId).toBe(prev.id);
    // Regression guard: if continue ever drops the space tag the child would be
    // null (personal) and the agent would run with the wrong MCP/SSH/folder set.
    expect(child.spaceId).toBe(spaceA.id);
  });

  // ── Visibility ──────────────────────────────────────────────────────────────

  it('visibility: updating a space task visibility persists and keeps the space tag', async () => {
    const spaceA = await caseSpace();
    const task = await repo.createLocalTask({
      title: 't', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id, visibility: 'private',
    });

    await repo.updateLocalTask(task.id, { visibility: 'public', visibilityScopeOrgId: null });
    let after = await repo.getLocalTask(task.id);
    expect(after?.visibility).toBe('public');
    expect(after?.spaceId).toBe(spaceA.id);

    await repo.updateLocalTask(task.id, { visibility: 'org', visibilityScopeOrgId: '10' });
    after = await repo.getLocalTask(task.id);
    expect(after?.visibility).toBe('org');
    expect(after?.visibilityScopeOrgId).toBe('10');
    expect(after?.spaceId).toBe(spaceA.id);
  });

  // ── Feedback ──────────────────────────────────────────────────────────────

  it('feedback: rating + tags persist on a space task', async () => {
    const spaceA = await caseSpace();
    const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id });

    await repo.updateFeedback(task.id, { rating: 'good', tags: ['accurate', 'fast'], comment: 'nice' });
    const after = await repo.getLocalTask(task.id);
    expect(after?.feedbackRating).toBe('good');
    expect(after?.feedbackTags).toEqual(['accurate', 'fast']);
    expect(after?.feedbackComment).toBe('nice');
    expect(after?.feedbackAt).toBeTruthy();
    expect(after?.spaceId).toBe(spaceA.id);
  });

  // ── Title ─────────────────────────────────────────────────────────────────

  it('title: updateLocalTask title (agent-owned regen path) persists on a space task', async () => {
    const spaceA = await caseSpace();
    const task = await repo.createLocalTask({
      title: 'old', titleSource: 'auto', body: 'b', ownerId: 'owner-1', spaceId: spaceA.id,
    });

    await repo.updateLocalTask(task.id, { title: 'regenerated title', titleSource: 'agent' });
    const after = await repo.getLocalTask(task.id);
    expect(after?.title).toBe('regenerated title');
    expect(after?.titleSource).toBe('agent');
    expect(after?.spaceId).toBe(spaceA.id);
  });
});

// ── Per-space settings folder isolation (AGENTS.md / memory / pieces) ─────────
//
// resolveTaskFolderContext maps a task to {rootDir, leafId} that feeds
// readUserAgentsMd / userPiecesDir / userMemoryDir (skills resolve the same
// way). A case-space task resolves to data/spaces/{spaceId}; a personal task
// resolves to data/users/{ownerId}. So a resource written under space A's
// folder must be invisible from space B and from the owner's personal folder.

describe('per-space settings folder isolation (resolveTaskFolderContext)', () => {
  let dir = '';
  let userFolderRoot = '';
  let repo: Repository;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), 'space-isolation-'));
    userFolderRoot = join(dir, 'data', 'users');
    repo = new Repository(join(dir, `${randomUUID()}.db`));
  });

  afterEach(() => {
    repo.close();
    rmSync(dir, { recursive: true, force: true });
  });

  it('a case-space task resolves under data/spaces/{spaceId}; personal under data/users/{owner}', async () => {
    const spaceA = await repo.createSpace({ kind: 'case', title: '案件A', ownerId: 'owner-7' });
    const caseTask = await repo.createLocalTask({ title: 't', body: '', ownerId: 'owner-7', spaceId: spaceA.id });
    const personalTask = await repo.createLocalTask({ title: 't', body: '', ownerId: 'owner-7' });

    const fcCase = await repo.resolveTaskFolderContext(caseTask.id, userFolderRoot);
    const fcPersonal = await repo.resolveTaskFolderContext(personalTask.id, userFolderRoot);

    expect(fcCase.leafId).toBe(spaceA.id);
    expect(fcCase.rootDir.endsWith(join('data', 'spaces'))).toBe(true);
    expect(fcPersonal).toEqual({ rootDir: userFolderRoot, leafId: 'owner-7' });
  });

  it('AGENTS.md written for space A is NOT visible from space B nor the personal space', async () => {
    const spaceA = await repo.createSpace({ kind: 'case', title: '案件A', ownerId: 'owner-7' });
    const spaceB = await repo.createSpace({ kind: 'case', title: '案件B', ownerId: 'owner-7' });
    const taskA = await repo.createLocalTask({ title: 't', body: '', ownerId: 'owner-7', spaceId: spaceA.id });
    const taskB = await repo.createLocalTask({ title: 't', body: '', ownerId: 'owner-7', spaceId: spaceB.id });
    const taskPersonal = await repo.createLocalTask({ title: 't', body: '', ownerId: 'owner-7' });

    const fcA = await repo.resolveTaskFolderContext(taskA.id, userFolderRoot);
    const fcB = await repo.resolveTaskFolderContext(taskB.id, userFolderRoot);
    const fcP = await repo.resolveTaskFolderContext(taskPersonal.id, userFolderRoot);

    // Write AGENTS.md only into space A's folder.
    writeUserAgentsMd(fcA.rootDir, fcA.leafId, '# Space A rules\nUse Japanese.');

    expect(readUserAgentsMd(fcA.rootDir, fcA.leafId)).toContain('Space A rules');
    // Space B and the personal space must see NOTHING.
    expect(readUserAgentsMd(fcB.rootDir, fcB.leafId)).toBeNull();
    expect(readUserAgentsMd(fcP.rootDir, fcP.leafId)).toBeNull();
  });

  it('memory + pieces dirs resolve to distinct per-space paths (no cross-space sharing)', async () => {
    const spaceA = await repo.createSpace({ kind: 'case', title: '案件A', ownerId: 'owner-7' });
    const spaceB = await repo.createSpace({ kind: 'case', title: '案件B', ownerId: 'owner-7' });
    const taskA = await repo.createLocalTask({ title: 't', body: '', ownerId: 'owner-7', spaceId: spaceA.id });
    const taskB = await repo.createLocalTask({ title: 't', body: '', ownerId: 'owner-7', spaceId: spaceB.id });

    const fcA = await repo.resolveTaskFolderContext(taskA.id, userFolderRoot);
    const fcB = await repo.resolveTaskFolderContext(taskB.id, userFolderRoot);

    const memA = userMemoryDir(fcA.rootDir, fcA.leafId);
    const memB = userMemoryDir(fcB.rootDir, fcB.leafId);
    const piecesA = userPiecesDir(fcA.rootDir, fcA.leafId);
    const piecesB = userPiecesDir(fcB.rootDir, fcB.leafId);

    expect(memA).not.toBe(memB);
    expect(piecesA).not.toBe(piecesB);
    // Each is namespaced under its own space id.
    expect(memA).toContain(spaceA.id);
    expect(memB).toContain(spaceB.id);
    expect(piecesA).toContain(spaceA.id);
    expect(piecesB).toContain(spaceB.id);
  });

  it('a task whose space was deleted falls back to the owner personal folder', async () => {
    const spaceA = await repo.createSpace({ kind: 'case', title: '案件A', ownerId: 'owner-7' });
    const task = await repo.createLocalTask({ title: 't', body: '', ownerId: 'owner-7', spaceId: spaceA.id });

    // Simulate the referenced space disappearing (archive→hard delete edge).
    repo.getDb().prepare('DELETE FROM spaces WHERE id = ?').run(spaceA.id);

    const fc = await repo.resolveTaskFolderContext(task.id, userFolderRoot);
    expect(fc).toEqual({ rootDir: userFolderRoot, leafId: 'owner-7' });
  });
});
