import type { PolicyDecision } from '@insignia/iios-contracts';

/**
 * Dev stand-in for the real OPA policy plane. It evaluates a small **policy table**
 * against the generic `{ action, ...context }` the kernel passes to `opa.decide`.
 * Everything defaults to ALLOW (so existing actions are unchanged); only the rules
 * below deny. This is the *policy plane*, NOT the kernel — the chat meaning of "dm"
 * vs "group" lives here as policy, and a real OPA drops in behind the same interface.
 */
export interface OpaInput {
  action?: string;
  membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel'
  visibility?: string; // 'public' | 'private' (channels)
  participantCount?: number;
  targetCount?: number; // how many participants a single add call is inviting (1 for a single add)
  callerRole?: string; // 'MEMBER' | 'ADMIN'
  alreadyMember?: boolean;
  isMember?: boolean;
  mime?: string;
  sizeBytes?: number;
  [k: string]: unknown;
}

const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
const MEDIA_ALLOWED = /^(image|video|audio)\//;
const MEDIA_ALLOWED_DOCS = new Set([
  'application/pdf', 'text/plain', 'text/markdown', 'text/x-markdown', 'text/html', 'text/csv', 'application/zip',
  'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
]);

const allow = (): PolicyDecision => ({ decisionRef: 'dev-allow', allow: true, obligations: [], ttlSeconds: 60 });
const deny = (reason: string): PolicyDecision => ({ decisionRef: 'dev-deny', allow: false, obligations: [{ kind: 'AUDIT', reason }], ttlSeconds: 0 });

export class DevOpaPort {
  async decide(input: unknown): Promise<PolicyDecision> {
    const i = (input ?? {}) as OpaInput;
    switch (i.action) {
      case 'iios.thread.participant.add': {
        const count = i.participantCount ?? 0;
        // `targetCount` is how many are being added in this call (1 for a single add), so the dm cap
        // holds for a bulk import too instead of being evaluated one-at-a-time.
        if (i.membership === 'dm' && count + (i.targetCount ?? 1) > 2) return deny('a direct message is limited to two people');
        if (i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can add participants');
        if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
        return allow();
      }
      case 'iios.thread.participant.list': {
        // Reading a thread's ROSTER. A public channel is open (it is discoverable anyway), but every
        // other membership thread — dm, group, private channel — requires you to be a member, or any
        // caller in the scope could enumerate a private channel's members by id. Ungoverned threads
        // (no membership attribute, e.g. support) keep the previous open behaviour.
        if (!i.membership) return allow();
        if (i.membership === 'channel' && i.visibility === 'public') return allow();
        if (i.callerRole === 'MEMBER' || i.callerRole === 'ADMIN') return allow();
        return deny('you are not a member of this thread');
      }
      case 'iios.thread.participant.remove': {
        // Same governance as add: for a group, only an ADMIN removes members. Ungoverned
        // (no membership attr) threads allow removal by any member.
        if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
        if (i.membership && i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can remove participants');
        return allow();
      }
      case 'iios.thread.update': {
        // Rename / settings change: for a group, only an ADMIN. Ungoverned threads allow it.
        if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can change group settings');
        return allow();
      }
      case 'iios.thread.join': {
        // Generic/support threads (no membership attr) keep open-join. A PUBLIC channel is the one
        // membership type that also allows open self-join. dm/group/private-channel are closed:
        // new members must be added first (governed add-participant).
        if (!i.membership) return allow();
        if (i.membership === 'channel' && i.visibility === 'public') return allow();
        return i.alreadyMember ? allow() : deny('you are not a member of this thread');
      }
      case 'iios.thread.discover': {
        // Browsing discoverable threads — the query fences to the caller's own scope, so allow.
        return allow();
      }
      case 'iios.thread.leave': {
        // Anyone may leave a thread they're in. (A membership thread implies the caller is a member.)
        return allow();
      }
      case 'iios.interaction.annotate': {
        // Annotating (e.g. reacting to) a message requires being a participant of its thread.
        return i.isMember ? allow() : deny('you are not a member of this thread');
      }
      case 'iios.media.upload': {
        const mime = i.mime ?? '';
        if ((i.sizeBytes ?? 0) > MEDIA_MAX_BYTES) return deny('file too large (max 25 MB)');
        if (!MEDIA_ALLOWED.test(mime) && !MEDIA_ALLOWED_DOCS.has(mime)) return deny(`file type not allowed: ${mime || 'unknown'}`);
        return allow();
      }
      default:
        return allow();
    }
  }
}
