import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Repository } from './repository.js';
import { canManageSpace, canEditInSpace } from '../bridge/visibility.js';

/**
 * 共有スペースのアクセス制御の中核テスト（security-critical）。
 * 「メンバーは他人所有の行も見える（協働保証）」と「非メンバーは見えない（漏洩防止）」を実証する。
 * spec: docs/superpowers/specs/2026-06-19-shared-space-design.md
 */

function freshRepo(): { repo: Repository; dir: string } {
  const dir = mkdtempSync(join(tmpdir(), 'shared-space-test-'));
  const repo = new Repository(join(dir, `${randomUUID()}.db`));
  return { repo, dir };
}

/** viewer 用 Express.User の最小構成。 */
function viewer(id: string, role: 'admin' | 'user' = 'user', orgIds: string[] = []): Express.User {
  return {
    id,
    email: `${id}@x.com`,
    name: id,
    avatarUrl: null,
    role,
    status: 'active',
    orgIds,
    defaultVisibility: 'private',
    defaultVisibilityOrgId: null,
  } as Express.User;
}

describe('space_members schema (triple-path)', () => {
  let repo: Repository;
  let dir: string;
  beforeEach(() => { ({ repo, dir } = freshRepo()); });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  it('creates space_members with the expected columns and (user_id) index', () => {
    // @ts-expect-error reach into the private db handle for a schema assertion
    const cols = repo.db.prepare("PRAGMA table_info('space_members')").all() as Array<{ name: string; pk: number }>;
    const names = cols.map(c => c.name).sort();
    expect(names).toEqual(['created_at', 'invited_by', 'role', 'space_id', 'user_id'].sort());
    // 複合 PK (space_id, user_id)
    const pkCols = cols.filter(c => c.pk > 0).map(c => c.name).sort();
    expect(pkCols).toEqual(['space_id', 'user_id'].sort());
    // @ts-expect-error private db handle
    const idx = repo.db.prepare("PRAGMA index_list('space_members')").all() as Array<{ name: string }>;
    expect(idx.some(i => i.name === 'idx_space_members_user')).toBe(true);
  });

  it("rejects an invalid role via CHECK constraint", () => {
    // @ts-expect-error private db handle
    const db = repo.db;
    expect(() =>
      db.prepare("INSERT INTO space_members (space_id, user_id, role) VALUES (?, ?, ?)").run('s', 'u', 'god'),
    ).toThrow();
  });
});

describe('Repository space member CRUD + getSpaceMemberRole', () => {
  let repo: Repository;
  let dir: string;
  beforeEach(() => { ({ repo, dir } = freshRepo()); });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  async function seed() {
    const owner = repo.createUser({ email: 'owner@x.com', name: 'Owner', role: 'user', status: 'active' });
    const member = repo.createUser({ email: 'member@x.com', name: 'Member', role: 'user', status: 'active' });
    const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId: owner.id, visibility: 'private' });
    return { owner, member, space };
  }

  it('addSpaceMember + listSpaceMembers joins user display info', async () => {
    const { member, space } = await seed();
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'editor', invitedBy: 'owner' });
    const members = await repo.listSpaceMembers(space.id);
    expect(members).toHaveLength(1);
    expect(members[0]).toMatchObject({
      spaceId: space.id, userId: member.id, role: 'editor', invitedBy: 'owner',
      name: 'Member', email: 'member@x.com',
    });
  });

  it('addSpaceMember is idempotent (ON CONFLICT updates role)', async () => {
    const { member, space } = await seed();
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer' });
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'editor' });
    expect((await repo.listSpaceMembers(space.id))).toHaveLength(1);
    expect(repo.getSpaceMemberRole(space.id, member.id)).toBe('editor');
  });

  it('getSpaceMemberRole returns the role, null for non-member, null for the root owner', async () => {
    const { owner, member, space } = await seed();
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer' });
    expect(repo.getSpaceMemberRole(space.id, member.id)).toBe('viewer');
    expect(repo.getSpaceMemberRole(space.id, 'nobody')).toBeNull();
    // root owner は members 行を持たない → null（呼び出し側が owner_id で別途 owner 扱い）
    expect(repo.getSpaceMemberRole(space.id, owner.id)).toBeNull();
    expect(canManageSpace(viewer(owner.id), { ownerId: owner.id }, repo.getSpaceMemberRole(space.id, owner.id))).toBe(true);
  });

  it('updateSpaceMemberRole / removeSpaceMember', async () => {
    const { member, space } = await seed();
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'editor' });
    await repo.updateSpaceMemberRole(space.id, member.id, 'owner');
    expect(repo.getSpaceMemberRole(space.id, member.id)).toBe('owner');
    await repo.removeSpaceMember(space.id, member.id);
    expect(repo.getSpaceMemberRole(space.id, member.id)).toBeNull();
    expect(await repo.listSpaceMembers(space.id)).toHaveLength(0);
  });

  it('ON DELETE CASCADE removes member rows when the space is hard-deleted', async () => {
    const { member, space } = await seed();
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'editor' });
    await repo.hardDeleteSpace(space.id);
    expect(repo.getSpaceMemberRole(space.id, member.id)).toBeNull();
  });
});

describe('membership-based visibility (collaboration guarantee + leak prevention)', () => {
  let repo: Repository;
  let dir: string;
  beforeEach(() => { ({ repo, dir } = freshRepo()); });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  // Space S は owner 所有・private。owner の private タスク/ジョブ/scheduled を 1 件ずつ作る。
  async function buildScenario() {
    const owner = repo.createUser({ email: 'owner@x.com', name: 'Owner', role: 'user', status: 'active' });
    const member = repo.createUser({ email: 'member@x.com', name: 'Member', role: 'user', status: 'active' });
    const stranger = repo.createUser({ email: 'stranger@x.com', name: 'Stranger', role: 'user', status: 'active' });
    const space = await repo.createSpace({ kind: 'case', title: '案件S', ownerId: owner.id, visibility: 'private' });

    // owner 所有の private 行（member から見えてはいけないが、メンバーシップで見えるべき）
    const task = await repo.createLocalTask({
      title: 'owner task', body: 'b', ownerId: owner.id, visibility: 'private', spaceId: space.id,
    });
    const job = await repo.createJob({
      repo: 'local/task-1', issueNumber: 1, instruction: 'x', ownerId: owner.id, visibility: 'private', spaceId: space.id,
    });
    const sched = await repo.createScheduledTask({
      body: 'x', cronExpression: '0 0 * * *', nextRunAt: new Date().toISOString(),
      ownerId: owner.id, visibility: 'private', spaceId: space.id,
    });
    return { owner, member, stranger, space, task, job, sched };
  }

  it('COLLABORATION: an editor member sees S + its space/task/job/scheduled rows OWNED BY ANOTHER USER', async () => {
    const { member, space, task, job, sched } = await buildScenario();
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'editor' });
    const v = viewer(member.id);

    // space itself
    expect((await repo.getSpace(space.id, { viewer: v }))?.id).toBe(space.id);
    expect((await repo.listSpaces({ viewer: v })).map(s => s.id)).toContain(space.id);
    // local task
    expect((await repo.getLocalTask(task.id, { viewer: v }))?.id).toBe(task.id);
    expect((await repo.listLocalTasks({ viewer: v })).map(t => t.id)).toContain(task.id);
    // job
    expect((await repo.getJob(job.id, { viewer: v }))?.id).toBe(job.id);
    // scheduled
    expect((await repo.getScheduledTask(sched.id, { viewer: v }))?.id).toBe(sched.id);
    expect((await repo.listScheduledTasks({ viewer: v })).map(s => s.id)).toContain(sched.id);
  });

  it('a viewer member also gets read access (read is membership-based, not role-based)', async () => {
    const { member, space, task } = await buildScenario();
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer' });
    const v = viewer(member.id);
    expect((await repo.getSpace(space.id, { viewer: v }))?.id).toBe(space.id);
    expect((await repo.getLocalTask(task.id, { viewer: v }))?.id).toBe(task.id);
  });

  it('LEAK: a NON-member sees none of S\'s private rows', async () => {
    const { stranger, space, task, job, sched } = await buildScenario();
    const v = viewer(stranger.id);
    expect(await repo.getSpace(space.id, { viewer: v })).toBeNull();
    expect((await repo.listSpaces({ viewer: v })).map(s => s.id)).not.toContain(space.id);
    expect(await repo.getLocalTask(task.id, { viewer: v })).toBeNull();
    expect((await repo.listLocalTasks({ viewer: v })).map(t => t.id)).not.toContain(task.id);
    expect(await repo.getJob(job.id, { viewer: v })).toBeNull();
    expect(await repo.getScheduledTask(sched.id, { viewer: v })).toBeNull();
    expect((await repo.listScheduledTasks({ viewer: v })).map(s => s.id)).not.toContain(sched.id);
  });

  it('REVOCATION: removing the member immediately revokes access', async () => {
    const { member, space, task, job, sched } = await buildScenario();
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'editor' });
    const v = viewer(member.id);
    // sanity: visible while member
    expect((await repo.getLocalTask(task.id, { viewer: v }))?.id).toBe(task.id);

    await repo.removeSpaceMember(space.id, member.id);

    expect(await repo.getSpace(space.id, { viewer: v })).toBeNull();
    expect(await repo.getLocalTask(task.id, { viewer: v })).toBeNull();
    expect(await repo.getJob(job.id, { viewer: v })).toBeNull();
    expect(await repo.getScheduledTask(sched.id, { viewer: v })).toBeNull();
    expect((await repo.listLocalTasks({ viewer: v })).map(t => t.id)).not.toContain(task.id);
  });

  it('membership in space S does NOT leak rows from an unrelated space S2', async () => {
    const { member, space } = await buildScenario();
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'editor' });
    const other = repo.createUser({ email: 'other@x.com', name: 'Other', role: 'user', status: 'active' });
    const space2 = await repo.createSpace({ kind: 'case', title: '案件S2', ownerId: other.id, visibility: 'private' });
    const task2 = await repo.createLocalTask({
      title: 's2 task', body: 'b', ownerId: other.id, visibility: 'private', spaceId: space2.id,
    });
    const v = viewer(member.id);
    expect(await repo.getSpace(space2.id, { viewer: v })).toBeNull();
    expect(await repo.getLocalTask(task2.id, { viewer: v })).toBeNull();
  });

  it('ADMIN unaffected: still sees everything (1=1, no membership subquery)', async () => {
    const { space, task, job, sched } = await buildScenario();
    const adminV = viewer('admin-x', 'admin');
    expect((await repo.getSpace(space.id, { viewer: adminV }))?.id).toBe(space.id);
    expect((await repo.getLocalTask(task.id, { viewer: adminV }))?.id).toBe(task.id);
    expect((await repo.getJob(job.id, { viewer: adminV }))?.id).toBe(job.id);
    expect((await repo.getScheduledTask(sched.id, { viewer: adminV }))?.id).toBe(sched.id);
  });
});

describe('GAP-1: root owner (no space_members row) sees OTHER members\' private rows', () => {
  let repo: Repository;
  let dir: string;
  beforeEach(() => { ({ repo, dir } = freshRepo()); });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  it('owner sees a MEMBER-owned private space task via getLocalTask + listLocalTasks', async () => {
    const owner = repo.createUser({ email: 'owner@x.com', name: 'O', role: 'user', status: 'active' });
    const member = repo.createUser({ email: 'member@x.com', name: 'M', role: 'user', status: 'active' });
    const space = await repo.createSpace({ kind: 'case', title: 'S', ownerId: owner.id, visibility: 'private' });
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'editor' });
    // 行は MEMBER 所有・private。owner は space_members 行を持たない（根オーナー）。
    const memberTask = await repo.createLocalTask({
      title: 'member task', body: 'b', ownerId: member.id, visibility: 'private', spaceId: space.id,
    });
    const v = viewer(owner.id);
    // 根オーナーは members 行が無くても spaces.owner_id 経由で閲覧できる（gap-1 fix）
    expect(repo.getSpaceMemberRole(space.id, owner.id)).toBeNull();
    expect((await repo.getLocalTask(memberTask.id, { viewer: v }))?.id).toBe(memberTask.id);
    expect((await repo.listLocalTasks({ viewer: v })).map(t => t.id)).toContain(memberTask.id);
  });

  it('member sees OTHER members\' (and the owner\'s) private space tasks', async () => {
    const owner = repo.createUser({ email: 'owner@x.com', name: 'O', role: 'user', status: 'active' });
    const m1 = repo.createUser({ email: 'm1@x.com', name: 'M1', role: 'user', status: 'active' });
    const m2 = repo.createUser({ email: 'm2@x.com', name: 'M2', role: 'user', status: 'active' });
    const space = await repo.createSpace({ kind: 'case', title: 'S', ownerId: owner.id, visibility: 'private' });
    await repo.addSpaceMember({ spaceId: space.id, userId: m1.id, role: 'viewer' });
    await repo.addSpaceMember({ spaceId: space.id, userId: m2.id, role: 'editor' });
    const ownerTask = await repo.createLocalTask({ title: 'owner', body: 'b', ownerId: owner.id, visibility: 'private', spaceId: space.id });
    const m2Task = await repo.createLocalTask({ title: 'm2', body: 'b', ownerId: m2.id, visibility: 'private', spaceId: space.id });
    const v = viewer(m1.id);
    expect((await repo.getLocalTask(ownerTask.id, { viewer: v }))?.id).toBe(ownerTask.id);
    expect((await repo.getLocalTask(m2Task.id, { viewer: v }))?.id).toBe(m2Task.id);
  });
});

describe('userCanViewSpace', () => {
  let repo: Repository;
  let dir: string;
  beforeEach(() => { ({ repo, dir } = freshRepo()); });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  it('admin → true; root owner → true; member → true; stranger → false', async () => {
    const owner = repo.createUser({ email: 'owner@x.com', name: 'O', role: 'user', status: 'active' });
    const member = repo.createUser({ email: 'member@x.com', name: 'M', role: 'user', status: 'active' });
    const stranger = repo.createUser({ email: 'stranger@x.com', name: 'S', role: 'user', status: 'active' });
    const space = await repo.createSpace({ kind: 'case', title: 'S', ownerId: owner.id, visibility: 'private' });
    await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer' });

    expect(repo.userCanViewSpace(space.id, { id: 'admin-x', role: 'admin' })).toBe(true);
    expect(repo.userCanViewSpace(space.id, { id: owner.id, role: 'user' })).toBe(true);
    expect(repo.userCanViewSpace(space.id, { id: member.id, role: 'user' })).toBe(true);
    expect(repo.userCanViewSpace(space.id, { id: stranger.id, role: 'user' })).toBe(false);
    // 存在しないスペース id → false（non-admin）
    expect(repo.userCanViewSpace('no-such-space', { id: owner.id, role: 'user' })).toBe(false);
  });
});

describe('write gates: canManageSpace / canEditInSpace with member roles', () => {
  let repo: Repository;
  let dir: string;
  beforeEach(() => { ({ repo, dir } = freshRepo()); });
  afterEach(() => { rmSync(dir, { recursive: true, force: true }); });

  it('truth table over admin / root-owner / member-owner / editor / viewer / non-member', async () => {
    const owner = repo.createUser({ email: 'owner@x.com', name: 'O', role: 'user', status: 'active' });
    const editorU = repo.createUser({ email: 'e@x.com', name: 'E', role: 'user', status: 'active' });
    const viewerU = repo.createUser({ email: 'v@x.com', name: 'V', role: 'user', status: 'active' });
    const ownerMemberU = repo.createUser({ email: 'om@x.com', name: 'OM', role: 'user', status: 'active' });
    const strangerU = repo.createUser({ email: 's@x.com', name: 'S', role: 'user', status: 'active' });
    const space = await repo.createSpace({ kind: 'case', title: 'T', ownerId: owner.id, visibility: 'private' });
    await repo.addSpaceMember({ spaceId: space.id, userId: editorU.id, role: 'editor' });
    await repo.addSpaceMember({ spaceId: space.id, userId: viewerU.id, role: 'viewer' });
    await repo.addSpaceMember({ spaceId: space.id, userId: ownerMemberU.id, role: 'owner' });

    const sp = { ownerId: space.ownerId };
    const roleOf = (id: string) => repo.getSpaceMemberRole(space.id, id);

    // canManageSpace
    expect(canManageSpace(viewer('admin-x', 'admin'), sp, roleOf('admin-x'))).toBe(true);
    expect(canManageSpace(viewer(owner.id), sp, roleOf(owner.id))).toBe(true);          // root owner
    expect(canManageSpace(viewer(ownerMemberU.id), sp, roleOf(ownerMemberU.id))).toBe(true); // member-owner
    expect(canManageSpace(viewer(editorU.id), sp, roleOf(editorU.id))).toBe(false);
    expect(canManageSpace(viewer(viewerU.id), sp, roleOf(viewerU.id))).toBe(false);
    expect(canManageSpace(viewer(strangerU.id), sp, roleOf(strangerU.id))).toBe(false);

    // canEditInSpace
    expect(canEditInSpace(viewer('admin-x', 'admin'), sp, roleOf('admin-x'))).toBe(true);
    expect(canEditInSpace(viewer(owner.id), sp, roleOf(owner.id))).toBe(true);
    expect(canEditInSpace(viewer(ownerMemberU.id), sp, roleOf(ownerMemberU.id))).toBe(true);
    expect(canEditInSpace(viewer(editorU.id), sp, roleOf(editorU.id))).toBe(true);      // editor CAN edit
    expect(canEditInSpace(viewer(viewerU.id), sp, roleOf(viewerU.id))).toBe(false);     // viewer read-only
    expect(canEditInSpace(viewer(strangerU.id), sp, roleOf(strangerU.id))).toBe(false);
  });
});
