/**
 * Integration test: Cross-user data isolation (M2-007)
 *
 * Verifies that every repository query path is scoped to the requesting user —
 * no user can read, write, or enumerate another user's records.
 *
 * Test strategy:
 *  - Two real users (User A, User B) are inserted directly into the database.
 *  - Realistic data (conversations + messages, agent configs, preferences,
 *    insights) is created for each user.
 *  - A shared system agent is inserted so both users can see it via
 *    findAccessible().
 *  - All assertions are made against the live database (no mocks).
 *  - All inserted rows are cleaned up in the afterAll hook.
 *
 * Requires: DATABASE_URL pointing at a running PostgreSQL instance with
 * pgvector enabled and the Mosaic schema already applied.
 */

import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createDb } from '@mosaicstack/db';
import { createConversationsRepo } from '@mosaicstack/brain';
import { createAgentsRepo } from '@mosaicstack/brain';
import { createPreferencesRepo, createInsightsRepo } from '@mosaicstack/memory';
import { users, conversations, messages, agents, preferences, insights } from '@mosaicstack/db';
import { eq } from '@mosaicstack/db';
import type { DbHandle } from '@mosaicstack/db';

// ─── Fixed IDs so the afterAll cleanup is deterministic ──────────────────────

const USER_A_ID = 'test-iso-user-a';
const USER_B_ID = 'test-iso-user-b';
const CONV_A_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
const CONV_B_ID = 'bbbbbbbb-0000-0000-0000-000000000001';
const MSG_A_ID = 'aaaaaaaa-0000-0000-0000-000000000002';
const MSG_B_ID = 'bbbbbbbb-0000-0000-0000-000000000002';
const AGENT_A_ID = 'aaaaaaaa-0000-0000-0000-000000000003';
const AGENT_B_ID = 'bbbbbbbb-0000-0000-0000-000000000003';
const AGENT_SYS_ID = 'ffffffff-0000-0000-0000-000000000001';
const PREF_A_ID = 'aaaaaaaa-0000-0000-0000-000000000004';
const PREF_B_ID = 'bbbbbbbb-0000-0000-0000-000000000004';
const INSIGHT_A_ID = 'aaaaaaaa-0000-0000-0000-000000000005';
const INSIGHT_B_ID = 'bbbbbbbb-0000-0000-0000-000000000005';

// ─── Test fixture ─────────────────────────────────────────────────────────────

let handle: DbHandle;
let dbAvailable = false;

beforeAll(async () => {
  try {
    handle = createDb();
    const db = handle.db;

    // Insert two users
    await db
      .insert(users)
      .values([
        {
          id: USER_A_ID,
          name: 'Isolation Test User A',
          email: 'test-iso-user-a@example.invalid',
          emailVerified: false,
        },
        {
          id: USER_B_ID,
          name: 'Isolation Test User B',
          email: 'test-iso-user-b@example.invalid',
          emailVerified: false,
        },
      ])
      .onConflictDoNothing();

    // Conversations — one per user
    await db
      .insert(conversations)
      .values([
        { id: CONV_A_ID, userId: USER_A_ID, title: 'User A conversation' },
        { id: CONV_B_ID, userId: USER_B_ID, title: 'User B conversation' },
      ])
      .onConflictDoNothing();

    // Messages — one per conversation
    await db
      .insert(messages)
      .values([
        {
          id: MSG_A_ID,
          conversationId: CONV_A_ID,
          role: 'user',
          content: 'Hello from User A',
        },
        {
          id: MSG_B_ID,
          conversationId: CONV_B_ID,
          role: 'user',
          content: 'Hello from User B',
        },
      ])
      .onConflictDoNothing();

    // Agent configs — private agents (one per user) + one system agent
    await db
      .insert(agents)
      .values([
        {
          id: AGENT_A_ID,
          name: 'Agent A (private)',
          provider: 'test',
          model: 'test-model',
          ownerId: USER_A_ID,
          isSystem: false,
        },
        {
          id: AGENT_B_ID,
          name: 'Agent B (private)',
          provider: 'test',
          model: 'test-model',
          ownerId: USER_B_ID,
          isSystem: false,
        },
        {
          id: AGENT_SYS_ID,
          name: 'Shared System Agent',
          provider: 'test',
          model: 'test-model',
          ownerId: null,
          isSystem: true,
        },
      ])
      .onConflictDoNothing();

    // Preferences — one per user (same key, different values)
    await db
      .insert(preferences)
      .values([
        {
          id: PREF_A_ID,
          userId: USER_A_ID,
          key: 'theme',
          value: 'dark',
          category: 'appearance',
        },
        {
          id: PREF_B_ID,
          userId: USER_B_ID,
          key: 'theme',
          value: 'light',
          category: 'appearance',
        },
      ])
      .onConflictDoNothing();

    // Insights — no embedding to keep the fixture simple; embedding-based search
    // is tested separately with a zero-vector that falls outside maxDistance
    await db
      .insert(insights)
      .values([
        {
          id: INSIGHT_A_ID,
          userId: USER_A_ID,
          content: 'User A insight',
          source: 'user',
          category: 'general',
          relevanceScore: 1.0,
        },
        {
          id: INSIGHT_B_ID,
          userId: USER_B_ID,
          content: 'User B insight',
          source: 'user',
          category: 'general',
          relevanceScore: 1.0,
        },
      ])
      .onConflictDoNothing();

    dbAvailable = true;
  } catch {
    // Database is not reachable (e.g., CI environment without Postgres on port 5433).
    // All tests in this suite will be skipped.
  }
});

// Skip all tests in this file when the database is not reachable (e.g., CI without Postgres).
beforeEach((ctx) => {
  if (!dbAvailable) {
    ctx.skip();
  }
});

afterAll(async () => {
  if (!handle) return;
  const db = handle.db;

  // Delete in dependency order (FK constraints)
  await db.delete(messages).where(eq(messages.id, MSG_A_ID));
  await db.delete(messages).where(eq(messages.id, MSG_B_ID));
  await db.delete(conversations).where(eq(conversations.id, CONV_A_ID));
  await db.delete(conversations).where(eq(conversations.id, CONV_B_ID));
  await db.delete(agents).where(eq(agents.id, AGENT_A_ID));
  await db.delete(agents).where(eq(agents.id, AGENT_B_ID));
  await db.delete(agents).where(eq(agents.id, AGENT_SYS_ID));
  await db.delete(preferences).where(eq(preferences.id, PREF_A_ID));
  await db.delete(preferences).where(eq(preferences.id, PREF_B_ID));
  await db.delete(insights).where(eq(insights.id, INSIGHT_A_ID));
  await db.delete(insights).where(eq(insights.id, INSIGHT_B_ID));
  await db.delete(users).where(eq(users.id, USER_A_ID));
  await db.delete(users).where(eq(users.id, USER_B_ID));

  await handle.close();
});

// ─── Conversations isolation ──────────────────────────────────────────────────

describe('ConversationsRepo — cross-user isolation', () => {
  it('User A can find their own conversation by id', async () => {
    const repo = createConversationsRepo(handle.db);
    const conv = await repo.findById(CONV_A_ID, USER_A_ID);
    expect(conv).toBeDefined();
    expect(conv!.id).toBe(CONV_A_ID);
  });

  it('User B cannot find User A conversation by id (returns undefined)', async () => {
    const repo = createConversationsRepo(handle.db);
    const conv = await repo.findById(CONV_A_ID, USER_B_ID);
    expect(conv).toBeUndefined();
  });

  it('User A cannot find User B conversation by id (returns undefined)', async () => {
    const repo = createConversationsRepo(handle.db);
    const conv = await repo.findById(CONV_B_ID, USER_A_ID);
    expect(conv).toBeUndefined();
  });

  it('findAll returns only own conversations for User A', async () => {
    const repo = createConversationsRepo(handle.db);
    const convs = await repo.findAll(USER_A_ID);
    const ids = convs.map((c) => c.id);
    expect(ids).toContain(CONV_A_ID);
    expect(ids).not.toContain(CONV_B_ID);
  });

  it('findAll returns only own conversations for User B', async () => {
    const repo = createConversationsRepo(handle.db);
    const convs = await repo.findAll(USER_B_ID);
    const ids = convs.map((c) => c.id);
    expect(ids).toContain(CONV_B_ID);
    expect(ids).not.toContain(CONV_A_ID);
  });
});

// ─── Messages isolation ───────────────────────────────────────────────────────

describe('ConversationsRepo.findMessages — cross-user isolation', () => {
  it('User A can read messages from their own conversation', async () => {
    const repo = createConversationsRepo(handle.db);
    const msgs = await repo.findMessages(CONV_A_ID, USER_A_ID);
    const ids = msgs.map((m) => m.id);
    expect(ids).toContain(MSG_A_ID);
  });

  it('User B cannot read messages from User A conversation (returns empty array)', async () => {
    const repo = createConversationsRepo(handle.db);
    const msgs = await repo.findMessages(CONV_A_ID, USER_B_ID);
    expect(msgs).toHaveLength(0);
  });

  it('User A cannot read messages from User B conversation (returns empty array)', async () => {
    const repo = createConversationsRepo(handle.db);
    const msgs = await repo.findMessages(CONV_B_ID, USER_A_ID);
    expect(msgs).toHaveLength(0);
  });

  it('addMessage is rejected when user does not own the conversation', async () => {
    const repo = createConversationsRepo(handle.db);
    const result = await repo.addMessage(
      {
        conversationId: CONV_A_ID,
        role: 'user',
        content: 'Attempted injection by User B',
      },
      USER_B_ID,
    );
    expect(result).toBeUndefined();
  });
});

// ─── Agent configs isolation ──────────────────────────────────────────────────

describe('AgentsRepo.findAccessible — cross-user isolation', () => {
  it('User A sees their own private agent', async () => {
    const repo = createAgentsRepo(handle.db);
    const accessible = await repo.findAccessible(USER_A_ID);
    const ids = accessible.map((a) => a.id);
    expect(ids).toContain(AGENT_A_ID);
  });

  it('User A does NOT see User B private agent', async () => {
    const repo = createAgentsRepo(handle.db);
    const accessible = await repo.findAccessible(USER_A_ID);
    const ids = accessible.map((a) => a.id);
    expect(ids).not.toContain(AGENT_B_ID);
  });

  it('User B does NOT see User A private agent', async () => {
    const repo = createAgentsRepo(handle.db);
    const accessible = await repo.findAccessible(USER_B_ID);
    const ids = accessible.map((a) => a.id);
    expect(ids).not.toContain(AGENT_A_ID);
  });

  it('Both users can see the shared system agent', async () => {
    const repo = createAgentsRepo(handle.db);
    const accessibleA = await repo.findAccessible(USER_A_ID);
    const accessibleB = await repo.findAccessible(USER_B_ID);
    expect(accessibleA.map((a) => a.id)).toContain(AGENT_SYS_ID);
    expect(accessibleB.map((a) => a.id)).toContain(AGENT_SYS_ID);
  });

  it('findSystem returns the system agent for any caller', async () => {
    const repo = createAgentsRepo(handle.db);
    const system = await repo.findSystem();
    const ids = system.map((a) => a.id);
    expect(ids).toContain(AGENT_SYS_ID);
  });

  it('update with ownerId prevents User B from modifying User A agent', async () => {
    const repo = createAgentsRepo(handle.db);
    const result = await repo.update(AGENT_A_ID, { model: 'hacked' }, USER_B_ID);
    expect(result).toBeUndefined();

    // Verify the agent was not actually mutated
    const unchanged = await repo.findById(AGENT_A_ID);
    expect(unchanged?.model).toBe('test-model');
  });

  it('remove prevents User B from deleting User A agent', async () => {
    const repo = createAgentsRepo(handle.db);
    const deleted = await repo.remove(AGENT_A_ID, USER_B_ID);
    expect(deleted).toBe(false);

    // Verify the agent still exists
    const still = await repo.findById(AGENT_A_ID);
    expect(still).toBeDefined();
  });
});

// ─── Preferences isolation ────────────────────────────────────────────────────

describe('PreferencesRepo — cross-user isolation', () => {
  it('User A can retrieve their own preferences', async () => {
    const repo = createPreferencesRepo(handle.db);
    const prefs = await repo.findByUser(USER_A_ID);
    const ids = prefs.map((p) => p.id);
    expect(ids).toContain(PREF_A_ID);
  });

  it('User A preferences do not contain User B preferences', async () => {
    const repo = createPreferencesRepo(handle.db);
    const prefs = await repo.findByUser(USER_A_ID);
    const ids = prefs.map((p) => p.id);
    expect(ids).not.toContain(PREF_B_ID);
  });

  it('User B preferences do not contain User A preferences', async () => {
    const repo = createPreferencesRepo(handle.db);
    const prefs = await repo.findByUser(USER_B_ID);
    const ids = prefs.map((p) => p.id);
    expect(ids).not.toContain(PREF_A_ID);
  });

  it('findByUserAndKey is scoped to the requesting user', async () => {
    const repo = createPreferencesRepo(handle.db);
    // Both users have key "theme" — each should only see their own value
    const prefA = await repo.findByUserAndKey(USER_A_ID, 'theme');
    const prefB = await repo.findByUserAndKey(USER_B_ID, 'theme');

    expect(prefA).toBeDefined();
    // Drizzle returns JSONB values as parsed JS values; '"dark"' (JSON string) → 'dark'
    expect(prefA!.value).toBe('dark');
    expect(prefB).toBeDefined();
    expect(prefB!.value).toBe('light');
  });

  it('remove is scoped to the requesting user (cannot delete another user pref)', async () => {
    const repo = createPreferencesRepo(handle.db);
    // User B tries to delete User A's "theme" preference — should silently fail
    const deleted = await repo.remove(USER_B_ID, 'theme');
    // This only deletes USER_B's own "theme" row; re-insert it for afterAll cleanup
    expect(deleted).toBe(true); // deletes User B's OWN theme pref

    // User A's theme pref must be untouched
    const prefA = await repo.findByUserAndKey(USER_A_ID, 'theme');
    expect(prefA).toBeDefined();

    // Re-insert User B's preference so afterAll cleanup still finds it
    await repo.upsert({
      id: PREF_B_ID,
      userId: USER_B_ID,
      key: 'theme',
      value: 'light',
      category: 'appearance',
    });
  });
});

// ─── Insights isolation ───────────────────────────────────────────────────────

describe('InsightsRepo — cross-user isolation', () => {
  it('User A can retrieve their own insights', async () => {
    const repo = createInsightsRepo(handle.db);
    const list = await repo.findByUser(USER_A_ID);
    const ids = list.map((i) => i.id);
    expect(ids).toContain(INSIGHT_A_ID);
  });

  it('User A insights do not contain User B insights', async () => {
    const repo = createInsightsRepo(handle.db);
    const list = await repo.findByUser(USER_A_ID);
    const ids = list.map((i) => i.id);
    expect(ids).not.toContain(INSIGHT_B_ID);
  });

  it('User B insights do not contain User A insights', async () => {
    const repo = createInsightsRepo(handle.db);
    const list = await repo.findByUser(USER_B_ID);
    const ids = list.map((i) => i.id);
    expect(ids).not.toContain(INSIGHT_A_ID);
  });

  it('findById is scoped to the requesting user', async () => {
    const repo = createInsightsRepo(handle.db);
    const own = await repo.findById(INSIGHT_A_ID, USER_A_ID);
    const cross = await repo.findById(INSIGHT_A_ID, USER_B_ID);

    expect(own).toBeDefined();
    expect(cross).toBeUndefined();
  });

  it('searchByEmbedding returns only own insights', async () => {
    const repo = createInsightsRepo(handle.db);
    // Our test insights have no embedding — the query filters WHERE embedding IS NOT NULL
    // so the result set is empty, which already proves no cross-user leakage.
    // Using a 1536-dimension zero vector as the query embedding.
    const zeroVector = Array<number>(1536).fill(0);

    const resultsA = await repo.searchByEmbedding(USER_A_ID, zeroVector, 50, 2.0);
    const resultsB = await repo.searchByEmbedding(USER_B_ID, zeroVector, 50, 2.0);

    // The raw SQL query returns row objects directly (not wrapped in { insight }).
    // Cast via unknown to extract id safely regardless of the return shape.
    const toId = (r: unknown): string =>
      ((r as Record<string, unknown>)['id'] as string | undefined) ??
      ((r as Record<string, Record<string, unknown>>)['insight']?.['id'] as string | undefined) ??
      '';
    const idsInA = resultsA.map(toId);
    const idsInB = resultsB.map(toId);

    // User B's insight must never appear in User A's search results
    expect(idsInA).not.toContain(INSIGHT_B_ID);
    // User A's insight must never appear in User B's search results
    expect(idsInB).not.toContain(INSIGHT_A_ID);
  });

  it('update is scoped to the requesting user', async () => {
    const repo = createInsightsRepo(handle.db);
    const result = await repo.update(INSIGHT_A_ID, USER_B_ID, { content: 'hacked' });
    expect(result).toBeUndefined();

    // Verify the insight was not mutated
    const unchanged = await repo.findById(INSIGHT_A_ID, USER_A_ID);
    expect(unchanged?.content).toBe('User A insight');
  });

  it('remove is scoped to the requesting user', async () => {
    const repo = createInsightsRepo(handle.db);
    const deleted = await repo.remove(INSIGHT_A_ID, USER_B_ID);
    expect(deleted).toBe(false);

    // Verify the insight still exists
    const still = await repo.findById(INSIGHT_A_ID, USER_A_ID);
    expect(still).toBeDefined();
  });
});
