// Space members CRUD + user-picker + member-role write gates, exercised through
// the HTTP API with a real auth matrix.
//
// Roles under test: root owner_id, admin, member-with-owner-role, editor member,
// viewer member, non-member. Each of GET/POST/PATCH/DELETE asserts the right
// 200/201/204/400/403/404. Read-gate (getSpace → 404) is always the first gate;
// member read access is granted by buildVisibilityWhere (stage 1).

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository } from '../db/repository.js';
import { createSpaceApi, type SpaceApiDeps } from './space-api.js';
import { mountUsersPickableApi } from './users-api.js';

function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
  return {
    id,
    email: `${id}@x.com`,
    name: id,
    avatarUrl: null,
    role,
    status: 'active',
    orgIds,
    defaultVisibility: 'private',
    defaultVisibilityOrgId: null,
  } as unknown as Express.User;
}

describe('space members API (HTTP auth matrix)', () => {
  let dir = '';
  let repo: Repository;

  // Persisted users (so getUserById resolves and pickable lists them).
  let ownerId = '';
  let adminId = '';
  let memberOwnerId = '';
  let editorId = '';
  let viewerId = '';
  let strangerId = '';
  let spaceId = '';

  function appAs(u: Express.User | null, authActive = true): express.Application {
    const app = express();
    if (u) {
      app.use((req, _res, next) => {
        (req as unknown as { user: Express.User }).user = u;
        next();
      });
    }
    const deps: SpaceApiDeps = {
      repo,
      dataRoot: join(dir, 'data', 'users'),
      worktreeDir: join(dir, 'workspaces'),
      authActive,
    };
    app.use('/api/local/spaces', createSpaceApi(deps));
    return app;
  }

  function pickableAppAs(u: Express.User | null, authActive = true): express.Application {
    const app = express();
    if (u) {
      app.use((req, _res, next) => {
        (req as unknown as { user: Express.User }).user = u;
        // requireAuth (auth.ts) calls req.isAuthenticated(), which passport
        // installs at runtime. Stub it here (admin-api.test.ts precedent).
        (req as unknown as { isAuthenticated: () => boolean }).isAuthenticated = () => true;
        next();
      });
    }
    mountUsersPickableApi(app, repo, authActive);
    return app;
  }

  beforeEach(async () => {
    dir = mkdtempSync(join(tmpdir(), 'space-members-'));
    repo = new Repository(join(dir, 'db.sqlite'));

    ownerId = repo.createUser({ email: 'owner@x.com', name: 'owner', role: 'user', status: 'active' }).id;
    adminId = repo.createUser({ email: 'admin@x.com', name: 'admin', role: 'admin', status: 'active' }).id;
    memberOwnerId = repo.createUser({ email: 'mowner@x.com', name: 'mowner', role: 'user', status: 'active' }).id;
    editorId = repo.createUser({ email: 'editor@x.com', name: 'editor', role: 'user', status: 'active' }).id;
    viewerId = repo.createUser({ email: 'viewer@x.com', name: 'viewer', role: 'user', status: 'active' }).id;
    strangerId = repo.createUser({ email: 'stranger@x.com', name: 'stranger', role: 'user', status: 'active' }).id;

    const space = await repo.createSpace({ kind: 'case', title: '案件', ownerId, visibility: 'private' });
    spaceId = space.id;
    await repo.addSpaceMember({ spaceId, userId: memberOwnerId, role: 'owner', invitedBy: ownerId });
    await repo.addSpaceMember({ spaceId, userId: editorId, role: 'editor', invitedBy: ownerId });
    await repo.addSpaceMember({ spaceId, userId: viewerId, role: 'viewer', invitedBy: ownerId });
  });

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

  // helper express.User shortcuts
  const owner = () => user(ownerId);
  const admin = () => user(adminId, 'admin');
  const mowner = () => user(memberOwnerId);
  const editor = () => user(editorId);
  const viewer = () => user(viewerId);
  const stranger = () => user(strangerId);

  // ── GET /:id/members ─────────────────────────────────────────────────────────

  it('GET members: owner sees synthesized owner row + members', async () => {
    const res = await request(appAs(owner())).get(`/api/local/spaces/${spaceId}/members`);
    expect(res.status).toBe(200);
    const byId = Object.fromEntries(res.body.map((m: any) => [m.userId, m]));
    expect(byId[ownerId]).toMatchObject({ role: 'owner', isOwner: true, name: 'owner' });
    expect(byId[memberOwnerId]).toMatchObject({ role: 'owner', isOwner: false });
    expect(byId[editorId]).toMatchObject({ role: 'editor', isOwner: false });
    expect(byId[viewerId]).toMatchObject({ role: 'viewer', isOwner: false });
    // owner row appears exactly once (de-dupe)
    expect(res.body.filter((m: any) => m.userId === ownerId)).toHaveLength(1);
  });

  it('GET members: admin (non-member) can read', async () => {
    const res = await request(appAs(admin())).get(`/api/local/spaces/${spaceId}/members`);
    expect(res.status).toBe(200);
    expect(res.body.length).toBe(4);
  });

  it('GET members: editor + viewer members can read', async () => {
    for (const u of [editor(), viewer()]) {
      const res = await request(appAs(u)).get(`/api/local/spaces/${spaceId}/members`);
      expect(res.status).toBe(200);
    }
  });

  it('GET members: non-member → 404 (read-gate)', async () => {
    const res = await request(appAs(stranger())).get(`/api/local/spaces/${spaceId}/members`);
    expect(res.status).toBe(404);
  });

  it('GET members: owner de-dupes when owner_id also has a member row (owner wins)', async () => {
    await repo.addSpaceMember({ spaceId, userId: ownerId, role: 'editor', invitedBy: ownerId });
    const res = await request(appAs(owner())).get(`/api/local/spaces/${spaceId}/members`);
    expect(res.status).toBe(200);
    const ownerRows = res.body.filter((m: any) => m.userId === ownerId);
    expect(ownerRows).toHaveLength(1);
    expect(ownerRows[0]).toMatchObject({ role: 'owner', isOwner: true });
  });

  // ── POST /:id/members ────────────────────────────────────────────────────────

  it('POST members: owner can add a new member', async () => {
    const res = await request(appAs(owner()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: strangerId, role: 'editor' });
    expect(res.status).toBe(201);
    expect(repo.getSpaceMemberRole(spaceId, strangerId)).toBe('editor');
  });

  it('POST members: admin can add', async () => {
    const res = await request(appAs(admin()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: strangerId, role: 'viewer' });
    expect(res.status).toBe(201);
  });

  it('POST members: member-with-owner-role can add', async () => {
    const res = await request(appAs(mowner()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: strangerId, role: 'viewer' });
    expect(res.status).toBe(201);
  });

  it('POST members: editor member CANNOT add → 403', async () => {
    const res = await request(appAs(editor()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: strangerId, role: 'viewer' });
    expect(res.status).toBe(403);
  });

  it('POST members: viewer member CANNOT add → 403', async () => {
    const res = await request(appAs(viewer()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: strangerId, role: 'viewer' });
    expect(res.status).toBe(403);
  });

  it('POST members: non-member → 404 (read-gate before 403)', async () => {
    const res = await request(appAs(stranger()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: adminId, role: 'viewer' });
    expect(res.status).toBe(404);
  });

  it('POST members: adding owner_id as a member → 400', async () => {
    const res = await request(appAs(owner()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: ownerId, role: 'editor' });
    expect(res.status).toBe(400);
  });

  it('POST members: unknown userId → 400', async () => {
    const res = await request(appAs(owner()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: 'nope-not-a-user', role: 'editor' });
    expect(res.status).toBe(400);
  });

  it('POST members: bad role → 400', async () => {
    const res = await request(appAs(owner()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: strangerId, role: 'superuser' });
    expect(res.status).toBe(400);
  });

  it('POST members: idempotent add upserts the role', async () => {
    await request(appAs(owner()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: strangerId, role: 'viewer' });
    const res = await request(appAs(owner()))
      .post(`/api/local/spaces/${spaceId}/members`)
      .send({ userId: strangerId, role: 'editor' });
    expect(res.status).toBe(201);
    expect(repo.getSpaceMemberRole(spaceId, strangerId)).toBe('editor');
  });

  // ── PATCH /:id/members/:userId ─────────────────────────────────────────────────

  it('PATCH member role: owner can change', async () => {
    const res = await request(appAs(owner()))
      .patch(`/api/local/spaces/${spaceId}/members/${editorId}`)
      .send({ role: 'viewer' });
    expect(res.status).toBe(200);
    expect(repo.getSpaceMemberRole(spaceId, editorId)).toBe('viewer');
  });

  it('PATCH member role: admin + member-owner can change', async () => {
    expect((await request(appAs(admin())).patch(`/api/local/spaces/${spaceId}/members/${editorId}`).send({ role: 'viewer' })).status).toBe(200);
    expect((await request(appAs(mowner())).patch(`/api/local/spaces/${spaceId}/members/${viewerId}`).send({ role: 'editor' })).status).toBe(200);
  });

  it('PATCH member role: editor + viewer members → 403', async () => {
    expect((await request(appAs(editor())).patch(`/api/local/spaces/${spaceId}/members/${viewerId}`).send({ role: 'editor' })).status).toBe(403);
    expect((await request(appAs(viewer())).patch(`/api/local/spaces/${spaceId}/members/${editorId}`).send({ role: 'viewer' })).status).toBe(403);
  });

  it('PATCH member role: non-member → 404', async () => {
    const res = await request(appAs(stranger()))
      .patch(`/api/local/spaces/${spaceId}/members/${editorId}`)
      .send({ role: 'viewer' });
    expect(res.status).toBe(404);
  });

  it('PATCH member role: not-a-member target → 404', async () => {
    const res = await request(appAs(owner()))
      .patch(`/api/local/spaces/${spaceId}/members/${strangerId}`)
      .send({ role: 'viewer' });
    expect(res.status).toBe(404);
  });

  it('PATCH member role: bad role → 400', async () => {
    const res = await request(appAs(owner()))
      .patch(`/api/local/spaces/${spaceId}/members/${editorId}`)
      .send({ role: 'god' });
    expect(res.status).toBe(400);
  });

  // ── DELETE /:id/members/:userId ────────────────────────────────────────────────

  it('DELETE member: owner can remove', async () => {
    const res = await request(appAs(owner())).delete(`/api/local/spaces/${spaceId}/members/${editorId}`);
    expect(res.status).toBe(204);
    expect(repo.getSpaceMemberRole(spaceId, editorId)).toBeNull();
  });

  it('DELETE member: admin + member-owner can remove', async () => {
    expect((await request(appAs(admin())).delete(`/api/local/spaces/${spaceId}/members/${editorId}`)).status).toBe(204);
    expect((await request(appAs(mowner())).delete(`/api/local/spaces/${spaceId}/members/${viewerId}`)).status).toBe(204);
  });

  it('DELETE member: editor cannot remove ANOTHER member → 403', async () => {
    const res = await request(appAs(editor())).delete(`/api/local/spaces/${spaceId}/members/${viewerId}`);
    expect(res.status).toBe(403);
  });

  it('DELETE member: a member CAN remove THEMSELVES (self-removal)', async () => {
    const res = await request(appAs(editor())).delete(`/api/local/spaces/${spaceId}/members/${editorId}`);
    expect(res.status).toBe(204);
    expect(repo.getSpaceMemberRole(spaceId, editorId)).toBeNull();
  });

  it('DELETE member: viewer CAN remove themselves', async () => {
    const res = await request(appAs(viewer())).delete(`/api/local/spaces/${spaceId}/members/${viewerId}`);
    expect(res.status).toBe(204);
  });

  it('DELETE member: non-member → 404 (read-gate)', async () => {
    const res = await request(appAs(stranger())).delete(`/api/local/spaces/${spaceId}/members/${editorId}`);
    expect(res.status).toBe(404);
  });

  it('DELETE member: not-a-member target (manager) → 404', async () => {
    const res = await request(appAs(owner())).delete(`/api/local/spaces/${spaceId}/members/${strangerId}`);
    expect(res.status).toBe(404);
  });

  // ── GET /api/users/pickable ────────────────────────────────────────────────────

  it('pickable: authed non-admin → 200 with active SAME-ORG users only', async () => {
    // owner + viewer share org "acme"; add a disabled + pending acme user (excluded),
    // and an active user in a DIFFERENT org "other" (excluded by org scoping).
    repo.replaceUserGiteaOrgs(ownerId, [{ orgId: 'acme', orgName: 'Acme' }]);
    repo.replaceUserGiteaOrgs(viewerId, [{ orgId: 'acme', orgName: 'Acme' }]);
    const disabled = repo.createUser({ email: 'dis@x.com', name: 'dis', role: 'user', status: 'disabled' }).id;
    const pending = repo.createUser({ email: 'pend@x.com', name: 'pend', role: 'user', status: 'pending' }).id;
    repo.replaceUserGiteaOrgs(disabled, [{ orgId: 'acme', orgName: 'Acme' }]);
    repo.replaceUserGiteaOrgs(pending, [{ orgId: 'acme', orgName: 'Acme' }]);
    const otherOrgUser = repo.createUser({ email: 'other@x.com', name: 'other', role: 'user', status: 'active' }).id;
    repo.replaceUserGiteaOrgs(otherOrgUser, [{ orgId: 'other', orgName: 'Other' }]);

    const res = await request(pickableAppAs(user(viewerId, 'user', ['acme']))).get('/api/users/pickable');
    expect(res.status).toBe(200);
    const ids = res.body.map((u: any) => u.id);
    expect(ids).toContain(ownerId);      // same org → present
    expect(ids).toContain(viewerId);     // requester is in acme → present
    expect(ids).not.toContain(otherOrgUser); // different org → absent
    expect(ids).not.toContain(disabled); // inactive → absent
    expect(ids).not.toContain(pending);  // inactive → absent
    // minimal shape — ONLY these keys (no role/status/token leak; regression guard)
    expect(Object.keys(res.body[0]).sort()).toEqual(['avatarUrl', 'email', 'id', 'name']);
  });

  it('pickable: local-org membership also counts as a shared org', async () => {
    const org = repo.createLocalOrg('Team', ownerId);
    repo.addOrgMember(org.id, ownerId, 'member');
    repo.addOrgMember(org.id, editorId, 'member');
    const res = await request(pickableAppAs(user(ownerId, 'user', [org.id]))).get('/api/users/pickable');
    expect(res.status).toBe(200);
    const ids = res.body.map((u: any) => u.id);
    expect(ids).toContain(editorId);
    expect(ids).not.toContain(strangerId); // not in the local org
  });

  it('pickable: regular requester with NO orgs → [] (privacy: no full directory leak)', async () => {
    const res = await request(pickableAppAs(user(strangerId, 'user', []))).get('/api/users/pickable');
    expect(res.status).toBe(200);
    expect(res.body).toEqual([]);
  });

  it('pickable: admin sees ALL active users regardless of org', async () => {
    repo.replaceUserGiteaOrgs(ownerId, [{ orgId: 'acme', orgName: 'Acme' }]);
    const res = await request(pickableAppAs(admin())).get('/api/users/pickable');
    expect(res.status).toBe(200);
    const ids = res.body.map((u: any) => u.id);
    // admin has no orgs but still sees everyone (orgless owner included)
    expect(ids).toContain(ownerId);
    expect(ids).toContain(editorId);
    expect(ids).toContain(strangerId);
    expect(Object.keys(res.body[0]).sort()).toEqual(['avatarUrl', 'email', 'id', 'name']);
  });

  it('pickable: no-auth mode → []', async () => {
    const res = await request(pickableAppAs(null, false)).get('/api/users/pickable');
    expect(res.status).toBe(200);
    expect(res.body).toEqual([]);
  });

  // ── GET /:id/members email masking (PII) ────────────────────────────────────
  // public/org スペースは非メンバーにも一覧が見える。email は資格者(admin/owner/
  // メンバー)にだけ返し、それ以外には name+avatar のみ（収集ベクター対策）。
  it('GET members: non-member viewer of a PUBLIC space sees names but NOT emails', async () => {
    const pub = await repo.createSpace({ kind: 'case', title: '公開', ownerId, visibility: 'public' });
    await repo.addSpaceMember({ spaceId: pub.id, userId: editorId, role: 'editor', invitedBy: ownerId });

    // stranger (non-member) can READ the public space's member list...
    const sres = await request(appAs(stranger())).get(`/api/local/spaces/${pub.id}/members`);
    expect(sres.status).toBe(200);
    const sByName = Object.fromEntries(sres.body.map((m: any) => [m.name, m]));
    expect(sByName['owner']).toBeTruthy();
    expect(sByName['editor']).toBeTruthy();
    // ...but every email is masked to null for the non-member.
    expect(sres.body.every((m: any) => m.email === null)).toBe(true);

    // a member (editor) DOES get emails.
    const eres = await request(appAs(editor())).get(`/api/local/spaces/${pub.id}/members`);
    expect(eres.status).toBe(200);
    const eByName = Object.fromEntries(eres.body.map((m: any) => [m.name, m]));
    expect(eByName['owner'].email).toBe('owner@x.com');
    expect(eByName['editor'].email).toBe('editor@x.com');
  });

  // ── myRole（閲覧者のメンバーロール）─ クライアントの canEditFiles 判定に使う ──────────

  it('GET /:id includes myRole for each member role', async () => {
    const cases: [Express.User, string | null][] = [
      [editor(), 'editor'],
      [viewer(), 'viewer'],
      [mowner(), 'owner'],
    ];
    for (const [u, expected] of cases) {
      const res = await request(appAs(u)).get(`/api/local/spaces/${spaceId}`);
      expect(res.status).toBe(200);
      expect(res.body.myRole).toBe(expected);
    }
  });

  it('GET /:id returns myRole null for the root owner (not a member row)', async () => {
    const res = await request(appAs(owner())).get(`/api/local/spaces/${spaceId}`);
    expect(res.status).toBe(200);
    expect(res.body.myRole).toBeNull();
  });

  it('GET / (list) tags each space with the viewer myRole', async () => {
    const res = await request(appAs(editor())).get('/api/local/spaces');
    expect(res.status).toBe(200);
    const mine = res.body.find((s: any) => s.id === spaceId);
    expect(mine?.myRole).toBe('editor');
  });
});
