/**
 * multi-user-isolation.test.ts — Multi-user shell + membrane isolation.
 *
 * Tests that two users (Alice and Scott) operating on the SAME runtime
 * get complete isolation. No state leakage between shell sessions,
 * no membrane cross-contamination, no visibility into each other's
 * definitions.
 *
 * Covers:
 *   - VISION.md §15 criteria 5, 9, 10 (parent membrane, capability scoping, fork isolation)
 *   - CONSTRAINTS.md Invariant 4: Shell = system service actor
 *   - CONSTRAINTS.md Invariant 6: Capability tokens = file descriptors
 *   - Multi-user adversarial isolation (the "Alice and Scott" pattern)
 *
 * Uses REAL MatrixRuntime + REAL ShellServiceActor (dynamic import
 * with graceful skip if omega-lisp unavailable).
 */
import '../../../_node-compat.js';

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixRuntime } from '../../../runtime/MatrixRuntime.js';
import { MatrixActor } from '../../MatrixActor.js';
import { TopicRouter } from '../../../engine/messaging/TopicRouter.js';
import type { MxEnvelope } from '../../../engine/messaging/MxEnvelope.js';
import {
  replyErrorMessage,
  type SkippableTestContext,
  type TestTransport,
  unwrapReply,
} from './helpers.js';

const tick = (ms = 50) => new Promise(r => setTimeout(r, ms));

interface ShellSessionRecord {
  id: string;
  owner?: string;
  [key: string]: unknown;
}

type ShellValue = number | { n?: number };

function shellNumericValue(value: ShellValue | undefined): number | undefined {
  return typeof value === 'number' ? value : value?.n;
}

interface ShellTestResult {
  ok?: boolean;
  sessionId?: string;
  status?: string;
  forkedFrom?: string;
  count?: number;
  sessions?: ShellSessionRecord[];
  value?: ShellValue;
  display?: string;
  componentClass?: string;
  [key: string]: unknown;
}

async function invoke<T = ShellTestResult>(
  transport: TestTransport,
  mount: string,
  op: string,
  payload?: unknown,
  timeoutMs = 5000,
): Promise<T> {
  return new Promise((resolve, reject) => {
    const cid = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    const replyTopic = `$replies.${cid}`;
    const timer = setTimeout(() => {
      unsub();
      reject(new Error(`Timeout waiting for reply to ${op} on ${mount}`));
    }, timeoutMs);

    const unsub = transport.subscribe(replyTopic, (msg: unknown) => {
      clearTimeout(timer);
      unsub();
      const reply = typeof msg === 'object' && msg !== null ? msg as { ok?: boolean; result?: T } : null;
      if (reply?.ok === true) resolve(reply.result as T);
      else if (reply?.ok === false) reject(new Error(replyErrorMessage(msg, JSON.stringify(msg))));
      else resolve(unwrapReply(msg) as T);
    });

    transport.publish(TopicRouter.inbox(mount), {
      op,
      payload: payload ?? {},
      replyTo: replyTopic,
      correlationId: cid,
    } as MxEnvelope);
  });
}

// ═══════════════════════════════════════════════════════════════════════════════
// MULTI-USER SHELL SESSION ISOLATION
// ═══════════════════════════════════════════════════════════════════════════════

describe('Multi-user isolation — shell sessions (Alice vs Scott)', () => {
  let runtime: MatrixRuntime;
  let ShellServiceActorClass: (new () => MatrixActor) | null = null;
  const shellMount = 'system.shell';

  before(async () => {
    try {
      const shellPath = '../../../../daemon/src/services/ShellServiceActor.js';
      const mod = await import(/* @vite-ignore */ shellPath);
      ShellServiceActorClass = mod.ShellServiceActor;
    } catch {
      // eslint-disable-next-line no-console -- optional shell dependency skip diagnostics keep test intent visible
      console.log('[multi-user-isolation] ShellServiceActor import failed — likely missing omega-lisp');
      return;
    }

    runtime = new MatrixRuntime({ logging: false });
    await tick(100);

    try {
      const ShellServiceActor = ShellServiceActorClass;
      if (!ShellServiceActor) return;
      await runtime.create(ShellServiceActor, shellMount);
      await tick(100);
    } catch (err) {
      // eslint-disable-next-line no-console -- optional shell dependency startup diagnostics keep skip reason visible
      console.log('[multi-user-isolation] Failed to create ShellServiceActor:', (err as Error).message);
      ShellServiceActorClass = null;
    }
  });

  after(async () => {
    if (runtime) await runtime.shutdown();
  });

  it('1. Alice and Scott get separate shell sessions', async function (this: SkippableTestContext) {
    if (!ShellServiceActorClass) return this.skip();

    const alice = await invoke(runtime.transport, shellMount, 'shell.create-session', {
      sessionId: 'alice-session',
      owner: 'alice',
    });
    const scott = await invoke(runtime.transport, shellMount, 'shell.create-session', {
      sessionId: 'scott-session',
      owner: 'scott',
    });

    assert.equal(alice.sessionId, 'alice-session');
    assert.equal(scott.sessionId, 'scott-session');
    assert.notEqual(alice.sessionId, scott.sessionId);
  });

  it('2. Alice defines variable — Scott CANNOT see it', async function (this: SkippableTestContext) {
    if (!ShellServiceActorClass) return this.skip();

    // Alice defines a secret value
    await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'alice-session',
      code: '(define alice-secret 42)',
    });

    // Alice can read it
    const aliceRead = await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'alice-session',
      code: 'alice-secret',
    });
    assert.equal(aliceRead.ok, true);
    assert.ok(
      aliceRead.display === '42' || aliceRead.value === 42 || shellNumericValue(aliceRead.value) === 42,
      `Alice should see 42, got: ${JSON.stringify(aliceRead)}`,
    );

    // Scott tries to read Alice's variable — should fail
    const scottRead = await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'scott-session',
      code: '(begin alice-secret)',
    });
    // If it succeeds, the value must NOT be 42
    if (scottRead.ok && (scottRead.display === '42' || scottRead.value === 42)) {
      assert.fail('Scott should NOT see Alice\'s definition');
    }
    // If it errors, that's the correct behavior
  });

  it('3. Scott defines variable — Alice CANNOT see it', async function (this: SkippableTestContext) {
    if (!ShellServiceActorClass) return this.skip();

    await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'scott-session',
      code: '(define scott-private 999)',
    });

    // Scott can read his own
    const scottRead = await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'scott-session',
      code: 'scott-private',
    });
    assert.ok(
      scottRead.display === '999' || scottRead.value === 999 || shellNumericValue(scottRead.value) === 999,
      `Scott should see 999`,
    );

    // Alice tries — should fail
    const aliceRead = await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'alice-session',
      code: '(begin scott-private)',
    });
    if (aliceRead.ok && (aliceRead.display === '999' || aliceRead.value === 999)) {
      assert.fail('Alice should NOT see Scott\'s definition');
    }
  });

  it('4. Concurrent evaluations in both sessions — no cross-contamination', async function (this: SkippableTestContext) {
    if (!ShellServiceActorClass) return this.skip();

    // Evaluate in both sessions simultaneously
    const [aliceResult, scottResult] = await Promise.all([
      invoke(runtime.transport, shellMount, 'shell.evaluate', {
        sessionId: 'alice-session',
        code: '(+ 100 200)',
      }),
      invoke(runtime.transport, shellMount, 'shell.evaluate', {
        sessionId: 'scott-session',
        code: '(+ 1 2)',
      }),
    ]);

    assert.equal(aliceResult.ok, true);
    assert.equal(scottResult.ok, true);

    // Results must be different — proving they ran in separate sandboxes
    assert.ok(
      aliceResult.display === '300' || aliceResult.value === 300 || shellNumericValue(aliceResult.value) === 300,
      `Alice should get 300, got: ${JSON.stringify(aliceResult)}`,
    );
    assert.ok(
      scottResult.display === '3' || scottResult.value === 3 || shellNumericValue(scottResult.value) === 3,
      `Scott should get 3, got: ${JSON.stringify(scottResult)}`,
    );
  });

  it('5. Alice forks session — fork is isolated from Scott', async function (this: SkippableTestContext) {
    if (!ShellServiceActorClass) return this.skip();

    const fork = await invoke(runtime.transport, shellMount, 'shell.fork-session', {
      sourceSessionId: 'alice-session',
      newSessionId: 'alice-fork',
    });
    assert.equal(fork.ok, true);
    assert.equal(fork.forkedFrom, 'alice-session');

    // Fork should see Alice's definitions
    const forkRead = await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'alice-fork',
      code: 'alice-secret',
    });
    assert.ok(
      forkRead.display === '42' || forkRead.value === 42 || shellNumericValue(forkRead.value) === 42,
      `Fork should see alice-secret=42`,
    );

    // Fork should NOT see Scott's definitions
    const forkScott = await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'alice-fork',
      code: '(begin scott-private)',
    });
    if (forkScott.ok && (forkScott.display === '999' || forkScott.value === 999)) {
      assert.fail('Alice\'s fork should NOT see Scott\'s definitions');
    }
  });

  it('6. Fork writes do NOT propagate back to Alice\'s original session', async function (this: SkippableTestContext) {
    if (!ShellServiceActorClass) return this.skip();

    // Write in fork
    await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'alice-fork',
      code: '(define fork-only-val 777)',
    });

    // Fork sees it
    const forkRead = await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'alice-fork',
      code: 'fork-only-val',
    });
    assert.ok(
      forkRead.display === '777' || forkRead.value === 777 || shellNumericValue(forkRead.value) === 777,
    );

    // Original Alice session does NOT see it
    const aliceRead = await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'alice-session',
      code: '(begin fork-only-val)',
    });
    if (aliceRead.ok && (aliceRead.display === '777' || aliceRead.value === 777)) {
      assert.fail('Fork write should NOT propagate back to parent');
    }
  });

  it('7. Session list shows all sessions with correct owners', async function (this: SkippableTestContext) {
    if (!ShellServiceActorClass) return this.skip();

    const list = await invoke(runtime.transport, shellMount, 'shell.list-sessions');
    assert.equal(list.ok, true);

    const sessions = list.sessions ?? [];
    const alice = sessions.find((session) => session.id === 'alice-session');
    const scott = sessions.find((session) => session.id === 'scott-session');
    const fork = sessions.find((session) => session.id === 'alice-fork');

    assert.ok(alice, 'Alice session should exist');
    assert.ok(scott, 'Scott session should exist');
    assert.ok(fork, 'Alice fork should exist');
    assert.equal(alice.owner, 'alice');
    assert.equal(scott.owner, 'scott');
  });

  it('8. Destroying Alice session does NOT affect Scott', async function (this: SkippableTestContext) {
    if (!ShellServiceActorClass) return this.skip();

    await invoke(runtime.transport, shellMount, 'shell.destroy-session', {
      sessionId: 'alice-session',
    });

    // Scott's session should still work
    const scottResult = await invoke(runtime.transport, shellMount, 'shell.evaluate', {
      sessionId: 'scott-session',
      code: 'scott-private',
    });
    assert.ok(
      scottResult.display === '999' || scottResult.value === 999 || shellNumericValue(scottResult.value) === 999,
      `Scott should still see his definitions after Alice's session is destroyed`,
    );
  });
});

// ═══════════════════════════════════════════════════════════════════════════════
// MULTI-USER MEMBRANE ISOLATION
// ═══════════════════════════════════════════════════════════════════════════════

describe('Multi-user isolation — membrane enforcement', () => {
  let runtime: MatrixRuntime;

  class UserActor extends MatrixActor {
    static accepts = {
      'user.action': { description: 'User action' },
      'user.read': { description: 'Read user data' },
    };
    onUserAction(payload: unknown) { return { ok: true, action: payload }; }
    onUserRead() { return { ok: true, data: 'user-data' }; }
  }

  before(async () => {
    runtime = new MatrixRuntime({ logging: false });
    await tick(100);

    // Two actors owned by different users
    await runtime.create(UserActor, 'users.alice.workspace');
    await runtime.create(UserActor, 'users.scott.workspace');
    await tick(100);
  });

  after(async () => {
    if (runtime) await runtime.shutdown();
  });

  it('9. Alice sets membrane on her actor — Scott\'s actor unaffected', async () => {
    // Alice sets deny-all on her workspace
    await invoke(runtime.transport, 'users.alice.workspace', '$membrane', {
      action: 'set-local',
      source: '(effect membrane.deny "ALICE_LOCKED")',
    });
    await tick(50);

    // Alice's workspace is locked
    try {
      await invoke(runtime.transport, 'users.alice.workspace', 'user.action', { test: true });
      assert.fail('Alice workspace should be locked');
    } catch (err: unknown) {
      assert.ok(err instanceof Error);
      assert.ok(err.message.includes('ALICE_LOCKED'));
    }

    // Scott's workspace is completely unaffected
    const scottResult = await invoke(runtime.transport, 'users.scott.workspace', 'user.action', { test: true });
    assert.equal(scottResult.ok, true);
  });

  it('10. Scott sets different membrane — both enforce independently', async () => {
    // Scott sets rate-limit membrane
    await invoke(runtime.transport, 'users.scott.workspace', '$membrane', {
      action: 'set-local',
      source: '(begin (effect membrane.rate-check "scott-ops" 2 10000) (effect membrane.allow op payload))',
    });
    await tick(50);

    // Scott's first 2 calls pass
    const r1 = await invoke(runtime.transport, 'users.scott.workspace', 'user.read');
    assert.equal(r1.ok, true);
    const r2 = await invoke(runtime.transport, 'users.scott.workspace', 'user.read');
    assert.equal(r2.ok, true);

    // Scott's 3rd call is rate-limited
    try {
      await invoke(runtime.transport, 'users.scott.workspace', 'user.read');
      assert.fail('Scott should be rate-limited');
    } catch {
      // Expected
    }

    // Alice is still deny-all (unchanged by Scott's membrane)
    try {
      await invoke(runtime.transport, 'users.alice.workspace', 'user.action', {});
      assert.fail('Alice should still be locked');
    } catch (err: unknown) {
      assert.ok(err instanceof Error);
      assert.ok(err.message.includes('ALICE_LOCKED'));
    }
  });

  it('11. System commands work on both actors regardless of membrane', async () => {
    const [alicePing, scottPing] = await Promise.all([
      invoke(runtime.transport, 'users.alice.workspace', '$ping'),
      invoke(runtime.transport, 'users.scott.workspace', '$ping'),
    ]);
    assert.equal(alicePing.status, 'ok');
    assert.equal(scottPing.status, 'ok');

    const [aliceIntro, scottIntro] = await Promise.all([
      invoke(runtime.transport, 'users.alice.workspace', '$introspect'),
      invoke(runtime.transport, 'users.scott.workspace', '$introspect'),
    ]);
    assert.equal(aliceIntro.componentClass, 'UserActor');
    assert.equal(scottIntro.componentClass, 'UserActor');
  });

  it('12. Clearing Alice membrane does not affect Scott membrane', async () => {
    await invoke(runtime.transport, 'users.alice.workspace', '$membrane', {
      action: 'clear-local',
    });
    await tick(50);

    // Alice works again
    const aliceResult = await invoke(runtime.transport, 'users.alice.workspace', 'user.action', { freed: true });
    assert.equal(aliceResult.ok, true);

    // Scott is STILL rate-limited (his membrane is independent)
    try {
      await invoke(runtime.transport, 'users.scott.workspace', 'user.read');
      // Might pass if rate limit window expired, that's ok
    } catch {
      // Still rate-limited — correct
    }
  });
});
