// スペース招待リンク（再利用トークン）の API テスト。
//
// 発行/無効化は canManageSpace、参加は認証済みユーザー。組織非依存の招待経路で、
// Google 認証など組織を持たないユーザーでもリンクで参加できることを担保する。
// spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md

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';

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 invite-link API', () => {
  let dir = '';
  let repo: Repository;
  let ownerId = '';
  let editorId = '';
  let strangerId = '';
  let spaceId = '';
  let worktreeDir = '';

  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,
      authActive,
    };
    app.use('/api/local/spaces', createSpaceApi(deps));
    return app;
  }

  beforeEach(async () => {
    dir = mkdtempSync(join(tmpdir(), 'space-invite-'));
    worktreeDir = join(dir, 'workspaces');
    repo = new Repository(join(dir, 'db.sqlite'));
    ownerId = repo.createUser({ email: 'owner@x.com', name: 'owner', role: 'user', status: 'active' }).id;
    editorId = repo.createUser({ email: 'editor@x.com', name: 'editor', 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: '案件A', ownerId, visibility: 'private' });
    spaceId = space.id;
    await repo.addSpaceMember({ spaceId, userId: editorId, role: 'editor', invitedBy: ownerId });
  });

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

  // ─── 発行 ───────────────────────────────────────────────

  it('owner generates a reusable link (token + url + role)', async () => {
    const res = await request(appAs(user(ownerId)))
      .post(`/api/local/spaces/${spaceId}/invite`)
      .send({ role: 'viewer' });
    expect(res.status).toBe(201);
    expect(res.body.invite.token).toBeTruthy();
    expect(res.body.invite.url).toBe(`/ui/invite/${res.body.invite.token}`);
    expect(res.body.invite.role).toBe('viewer');
    expect(res.body.invite.valid).toBe(true);
  });

  it('regenerating replaces the old token (old token becomes invalid)', async () => {
    const app = appAs(user(ownerId));
    const first = (await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer' })).body.invite.token;
    const second = (await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'editor' })).body.invite.token;
    expect(second).not.toBe(first);
    // old token no longer previews
    expect((await request(app).get(`/api/local/spaces/invite/${first}`)).status).toBe(404);
    // new token previews with the new role
    const prev = await request(app).get(`/api/local/spaces/invite/${second}`);
    expect(prev.status).toBe(200);
    expect(prev.body.role).toBe('editor');
  });

  it('GET returns the current invite, or null when none', async () => {
    const app = appAs(user(ownerId));
    expect((await request(app).get(`/api/local/spaces/${spaceId}/invite`)).body.invite).toBeNull();
    await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer' });
    expect((await request(app).get(`/api/local/spaces/${spaceId}/invite`)).body.invite.role).toBe('viewer');
  });

  it('rejects invalid role and the owner role (no privilege escalation)', async () => {
    const app = appAs(user(ownerId));
    expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'owner' })).status).toBe(400);
    expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'bogus' })).status).toBe(400);
  });

  it('rejects a non-positive expiresInDays', async () => {
    const app = appAs(user(ownerId));
    expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer', expiresInDays: 0 })).status).toBe(400);
    expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer', expiresInDays: -3 })).status).toBe(400);
    expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer', expiresInDays: 7 })).status).toBe(201);
  });

  // ─── 認可 ───────────────────────────────────────────────

  it('non-manager member (editor) cannot generate → 403', async () => {
    const res = await request(appAs(user(editorId)))
      .post(`/api/local/spaces/${spaceId}/invite`)
      .send({ role: 'viewer' });
    expect(res.status).toBe(403);
  });

  it('non-member cannot generate → 404 (space not visible)', async () => {
    const res = await request(appAs(user(strangerId)))
      .post(`/api/local/spaces/${spaceId}/invite`)
      .send({ role: 'viewer' });
    expect(res.status).toBe(404);
  });

  it('admin (non-owner) can generate', async () => {
    const adminId = repo.createUser({ email: 'a@x.com', name: 'a', role: 'admin', status: 'active' }).id;
    const res = await request(appAs(user(adminId, 'admin')))
      .post(`/api/local/spaces/${spaceId}/invite`)
      .send({ role: 'editor' });
    expect(res.status).toBe(201);
  });

  // ─── 参加 ───────────────────────────────────────────────

  it('a stranger with a valid token joins as the link role, idempotently', async () => {
    const ownerApp = appAs(user(ownerId));
    const token = (await request(ownerApp).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer' })).body.invite.token;

    const strangerApp = appAs(user(strangerId));
    // preview is visible to a non-member (must be logged in; here injected)
    const prev = await request(strangerApp).get(`/api/local/spaces/invite/${token}`);
    expect(prev.status).toBe(200);
    expect(prev.body).toMatchObject({ spaceId, spaceTitle: '案件A', role: 'viewer' });

    const accept = await request(strangerApp).post(`/api/local/spaces/invite/${token}/accept`);
    expect(accept.status).toBe(200);
    expect(accept.body).toMatchObject({ spaceId, alreadyMember: false });
    expect(repo.getSpaceMemberRole(spaceId, strangerId)).toBe('viewer');

    // accepting again is idempotent
    const again = await request(strangerApp).post(`/api/local/spaces/invite/${token}/accept`);
    expect(again.status).toBe(200);
    expect(again.body.alreadyMember).toBe(true);
  });

  it('the owner accepting their own link is a no-op success (no member row)', async () => {
    const ownerApp = appAs(user(ownerId));
    const token = (await request(ownerApp).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'editor' })).body.invite.token;
    const accept = await request(ownerApp).post(`/api/local/spaces/invite/${token}/accept`);
    expect(accept.status).toBe(200);
    expect(accept.body.alreadyMember).toBe(true);
    expect(repo.getSpaceMemberRole(spaceId, ownerId)).toBeNull(); // owner has no member row
  });

  // ─── 無効化・無効トークン ────────────────────────────────

  it('a revoked link no longer previews or accepts (404)', async () => {
    const ownerApp = appAs(user(ownerId));
    const token = (await request(ownerApp).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer' })).body.invite.token;
    expect((await request(ownerApp).delete(`/api/local/spaces/${spaceId}/invite`)).status).toBe(204);

    const strangerApp = appAs(user(strangerId));
    expect((await request(strangerApp).get(`/api/local/spaces/invite/${token}`)).status).toBe(404);
    expect((await request(strangerApp).post(`/api/local/spaces/invite/${token}/accept`)).status).toBe(404);
    expect(repo.getSpaceMemberRole(spaceId, strangerId)).toBeNull();
  });

  it('an unknown token previews/accepts as 404 (leaks no space info)', async () => {
    const strangerApp = appAs(user(strangerId));
    const prev = await request(strangerApp).get(`/api/local/spaces/invite/does-not-exist`);
    expect(prev.status).toBe(404);
    expect(prev.body.spaceTitle).toBeUndefined();
    expect((await request(strangerApp).post(`/api/local/spaces/invite/does-not-exist/accept`)).status).toBe(404);
  });

  // ─── no-auth モード ──────────────────────────────────────

  it('all invite endpoints are 404 in no-auth mode', async () => {
    const app = appAs(null, false); // authActive=false → viewerOf = local admin, but invites disabled
    expect((await request(app).get(`/api/local/spaces/${spaceId}/invite`)).status).toBe(404);
    expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer' })).status).toBe(404);
    expect((await request(app).delete(`/api/local/spaces/${spaceId}/invite`)).status).toBe(404);
    expect((await request(app).get(`/api/local/spaces/invite/whatever`)).status).toBe(404);
    expect((await request(app).post(`/api/local/spaces/invite/whatever/accept`)).status).toBe(404);
  });
});
