import { describe, it, expect } from 'vitest';
import { DevOpaPort } from './dev-opa.port';

const opa = new DevOpaPort();

describe('DevOpaPort (dev policy plane — membership rules)', () => {
  it('allows every existing action by default (thread create/read, message send)', async () => {
    for (const action of ['iios.thread.create', 'iios.thread.read', 'iios.message.send', undefined]) {
      expect((await opa.decide({ action })).allow).toBe(true);
    }
  });

  it('DM is capped at two: adding a 2nd is allowed, a 3rd is denied', async () => {
    const add = (participantCount: number) =>
      opa.decide({ action: 'iios.thread.participant.add', membership: 'dm', callerRole: 'MEMBER', participantCount });
    expect((await add(1)).allow).toBe(true); // 1 → adding the 2nd
    const third = await add(2); // 2 → adding a 3rd
    expect(third.allow).toBe(false);
    expect(third.obligations[0]?.reason).toMatch(/two people/);
  });

  it('adding a participant requires the caller be a member', async () => {
    const d = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'NONE', participantCount: 3 });
    expect(d.allow).toBe(false);
    expect(d.obligations[0]?.reason).toMatch(/member/);
  });

  it('group add/remove requires ADMIN', async () => {
    const asMember = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'MEMBER', participantCount: 3 });
    expect(asMember.allow).toBe(false);
    expect(asMember.obligations[0]?.reason).toMatch(/admin/);
    const asAdmin = await opa.decide({ action: 'iios.thread.participant.add', membership: 'group', callerRole: 'ADMIN', participantCount: 3 });
    expect(asAdmin.allow).toBe(true);
  });

  it('group rename (thread.update) requires ADMIN; ungoverned threads allow it', async () => {
    const asMember = await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'MEMBER' });
    expect(asMember.allow).toBe(false);
    expect(asMember.obligations[0]?.reason).toMatch(/admin/);
    expect((await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
    expect((await opa.decide({ action: 'iios.thread.update' })).allow).toBe(true); // no membership attr → allow
  });

  it('group remove requires ADMIN; a member on an ungoverned thread may remove', async () => {
    const asMember = await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'MEMBER' });
    expect(asMember.allow).toBe(false);
    expect(asMember.obligations[0]?.reason).toMatch(/admin/);
    expect((await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
    expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
  });

  it('the dm cap counts the whole batch, not one add at a time', async () => {
    const add = (participantCount: number, targetCount: number) =>
      opa.decide({ action: 'iios.thread.participant.add', membership: 'dm', callerRole: 'MEMBER', participantCount, targetCount });
    expect((await add(1, 1)).allow).toBe(true); // 1 + 1 = 2, fine
    const batch = await add(1, 2); // 1 + 2 = 3 — must be denied even though count is only 1
    expect(batch.allow).toBe(false);
    expect(batch.obligations[0]?.reason).toMatch(/two people/);
  });

  it('listing a roster requires membership, except on a public channel', async () => {
    const list = (extra: Record<string, unknown>) => opa.decide({ action: 'iios.thread.participant.list', ...extra });
    // a private channel / group / dm: members only
    const stranger = await list({ membership: 'channel', visibility: 'private', callerRole: undefined });
    expect(stranger.allow).toBe(false);
    expect(stranger.obligations[0]?.reason).toMatch(/not a member/);
    expect((await list({ membership: 'channel', visibility: 'private', callerRole: 'MEMBER' })).allow).toBe(true);
    expect((await list({ membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
    expect((await list({ membership: 'group', callerRole: undefined })).allow).toBe(false);
    // a public channel's roster is open, and ungoverned threads are unchanged
    expect((await list({ membership: 'channel', visibility: 'public', callerRole: undefined })).allow).toBe(true);
    expect((await list({})).allow).toBe(true);
  });

  it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
    const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
    for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
      expect((await up(mime)).allow).toBe(true);
    }
    const bad = await up('application/x-msdownload');
    expect(bad.allow).toBe(false);
    expect(bad.obligations[0]?.reason).toMatch(/not allowed/);
    const big = await up('image/png', 30 * 1024 * 1024);
    expect(big.allow).toBe(false);
    expect(big.obligations[0]?.reason).toMatch(/too large/);
  });

  it('a PUBLIC channel allows open self-join; private channel and group do not', async () => {
    expect((await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'public', alreadyMember: false })).allow).toBe(true);
    const priv = await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'private', alreadyMember: false });
    expect(priv.allow).toBe(false);
    expect((await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: false })).allow).toBe(false);
    // an existing member of a private channel can still (re)open it
    expect((await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'private', alreadyMember: true })).allow).toBe(true);
  });

  it('discover and leave are allowed (scope fence / member-implied)', async () => {
    expect((await opa.decide({ action: 'iios.thread.discover' })).allow).toBe(true);
    expect((await opa.decide({ action: 'iios.thread.leave', membership: 'channel' })).allow).toBe(true);
  });

  it('self-join is governed only on membership threads', async () => {
    // generic / support thread (no membership attr) → open join, unchanged
    expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
    // a membership (chat) thread → existing member allowed, stranger denied
    expect((await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: true })).allow).toBe(true);
    const stranger = await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: false });
    expect(stranger.allow).toBe(false);
    expect(stranger.obligations[0]?.reason).toMatch(/not a member/);
  });
});
