/**
 * That a Piggy transcript belongs to exactly one person.
 *
 * The failure this suite exists to prevent is not exotic. Every statement in
 * `PiggyConversationService` carries `user_id = $me`; the day one of them does
 * not, the route above it keeps working perfectly for its author and quietly
 * starts answering for everybody else's history too, with no error anywhere.
 * So the assertions are made twice, at two different depths:
 *
 *   - against a recording driver, which runs in the default suite and pins
 *     that the predicate actually reaches SQL on every path, including the
 *     ones a fake row store would happily let through;
 *   - against a real Postgres, which is where a cascade, a unique key and a
 *     CHECK constraint are either true or not. That half needs a database and
 *     therefore names its own:
 *
 *         createdb pig_piggy_test
 *         DATABASE_URL=postgres://…/pig_piggy_test pnpm -F @pig/db run migrate
 *         PIG_TEST_DATABASE_URL=postgres://…/pig_piggy_test \
 *           pnpm -F @pig/api run test
 *
 *     A deliberately separate variable from `DATABASE_URL`: this suite writes
 *     and deletes rows, and it must be impossible to point it at a working
 *     database by inheriting the environment.
 */
import { strict as assert } from 'node:assert';
import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { after, describe, it } from 'node:test';
import { drizzle } from 'drizzle-orm/pg-proxy';
import { eq, inArray } from 'drizzle-orm';
import { Hono } from 'hono';
import type { Database } from '@pig/db';
import { AuthError } from '../src/lib/auth';
import { apiError, type ApiEnv } from '../src/lib/mutation';
import { createPiggyConversationRoutes } from '../src/routes/piggy-conversations';
import {
  derivePiggyTitle,
  PIGGY_TITLE_MAX,
  PIGGY_UNTITLED,
  PiggyConversationService,
  PiggyTurnRecorder,
} from '../src/services/piggy-conversations';
import { principal as makePrincipal } from './helpers/principal';

const ME = '00000000-0000-4000-8000-0000000000aa';
const SOMEONE_ELSE_CONVERSATION = '00000000-0000-4000-8000-0000000000cc';

// ------------------------------------------------------------------- titles

describe('conversation titles', () => {
  it('names a thread after the first thing said in it', () => {
    assert.equal(derivePiggyTitle('Which suppliers are idle this month?'), 'Which suppliers are idle this month?');
  });

  it('collapses a pasted block so a sidebar row stays one line', () => {
    assert.equal(derivePiggyTitle('  Log a call\n\non Northwind   Robotics '), 'Log a call on Northwind Robotics');
  });

  it('cuts on a word boundary and stays inside the budget', () => {
    const long = `${'word '.repeat(60)}end`;
    const title = derivePiggyTitle(long);
    assert.ok(title.length <= PIGGY_TITLE_MAX, `${title.length} exceeds ${PIGGY_TITLE_MAX}`);
    assert.ok(title.endsWith('…'));
    assert.ok(!title.includes('  '));
  });

  it('falls back rather than storing an empty title', () => {
    // The column has a CHECK on length > 0; an empty first message must not
    // reach it, because a constraint violation here would fail the turn.
    assert.equal(derivePiggyTitle(''), PIGGY_UNTITLED);
    assert.equal(derivePiggyTitle('   '), PIGGY_UNTITLED);
    assert.equal(derivePiggyTitle(undefined), PIGGY_UNTITLED);
  });
});

// -------------------------------------------------- the predicate reaches SQL

interface Statement {
  sql: string;
  params: unknown[];
}

/**
 * A driver that answers nothing and remembers everything.
 *
 * Empty results are the point: to this database every conversation belongs to
 * somebody else, which is exactly the state a caller reaching for another
 * person's thread is in. A method that only appears to be scoped — reading the
 * row and comparing the owner afterwards — would return it anyway; one that
 * puts the owner in the WHERE clause returns nothing, and the statements it
 * issued are here to be read.
 */
function recordingDatabase(): { db: Database; statements: Statement[] } {
  const statements: Statement[] = [];
  const base = drizzle(async (sql: string, params: unknown[]) => {
    statements.push({ sql, params });
    return { rows: [] };
  });

  const db = new Proxy(base, {
    get(target, property) {
      // The proxy driver refuses transactions outright, and `appendMessage`
      // opens one. Running the body inline is sound here because nothing in
      // this half asserts atomicity — the real-database half does.
      if (property === 'transaction') {
        return async (work: (tx: unknown) => Promise<unknown>) => work(db);
      }
      const value = Reflect.get(target, property);
      return typeof value === 'function' ? value.bind(target) : value;
    },
  }) as unknown as Database;

  return { db, statements };
}

function touching(statements: Statement[], table: string): Statement[] {
  return statements.filter((statement) => statement.sql.includes(table));
}

function assertScopedTo(statements: Statement[], userId: string, what: string): void {
  const relevant = touching(statements, 'piggy_conversations');
  assert.ok(relevant.length > 0, `${what} issued no statement against piggy_conversations`);
  for (const statement of relevant) {
    assert.ok(
      statement.sql.includes('"user_id"'),
      `${what} reached piggy_conversations without naming an owner:\n${statement.sql}`,
    );
    assert.ok(
      statement.params.includes(userId),
      `${what} did not bind the caller's own id:\n${statement.sql}\n${JSON.stringify(statement.params)}`,
    );
  }
}

describe('every path is scoped to the caller', () => {
  const me = makePrincipal({ userId: ME });

  it('lists only my conversations', async () => {
    const { db, statements } = recordingDatabase();
    await new PiggyConversationService(db).list(me);
    assertScopedTo(statements, ME, 'list');
  });

  it('reads a transcript only when it is mine', async () => {
    const { db, statements } = recordingDatabase();
    const detail = await new PiggyConversationService(db).detail(me, SOMEONE_ELSE_CONVERSATION);
    assert.equal(detail, null);
    assertScopedTo(statements, ME, 'detail');
    // Nothing was read out of the transcript itself, so an id belonging to
    // someone else cannot leak a message count, let alone a message.
    assert.equal(touching(statements, 'piggy_messages').length, 0);
  });

  it('replays history only from my own thread', async () => {
    const { db, statements } = recordingDatabase();
    assert.deepEqual(
      await new PiggyConversationService(db).promptHistory(me, SOMEONE_ELSE_CONVERSATION),
      [],
    );
    assertScopedTo(statements, ME, 'promptHistory');
    assert.equal(touching(statements, 'piggy_messages').length, 0);
  });

  it('renames with the owner in the UPDATE, not in a check afterwards', async () => {
    const { db, statements } = recordingDatabase();
    const renamed = await new PiggyConversationService(db).rename(
      me,
      SOMEONE_ELSE_CONVERSATION,
      'Mine now',
    );
    assert.equal(renamed, null);
    assertScopedTo(statements, ME, 'rename');
    assert.ok(statements.every((s) => s.sql.trimStart().toLowerCase().startsWith('update')));
  });

  it('deletes with the owner in the DELETE', async () => {
    const { db, statements } = recordingDatabase();
    assert.equal(await new PiggyConversationService(db).remove(me, SOMEONE_ELSE_CONVERSATION), false);
    assertScopedTo(statements, ME, 'remove');
    assert.ok(statements.every((s) => s.sql.trimStart().toLowerCase().startsWith('delete')));
  });

  it('writes nothing into a conversation that is not mine', async () => {
    const { db, statements } = recordingDatabase();
    const appended = await new PiggyConversationService(db).appendMessage(
      me,
      SOMEONE_ELSE_CONVERSATION,
      { role: 'user', content: 'Log a call on Northwind Robotics' },
    );
    assert.equal(appended, null);
    assertScopedTo(statements, ME, 'appendMessage');
    // The whole point: the ownership select fails closed, so no message row
    // and no timestamp bump ever reaches someone else's thread.
    assert.equal(
      statements.filter((s) => s.sql.toLowerCase().startsWith('insert')).length,
      0,
    );
  });

  /**
   * The one statement here that does not touch `piggy_conversations`, and so
   * the one the shared assertion above cannot cover. The conversation id
   * travels through a browser, so without the owner in the WHERE clause this
   * would be a way to re-point a colleague's inference spend at your own thread.
   */
  it('stamps the ledger only for the caller’s own runs', async () => {
    const { db, statements } = recordingDatabase();
    await new PiggyConversationService(db).linkAgentRuns(me, SOMEONE_ELSE_CONVERSATION);
    const relevant = touching(statements, 'agent_runs');
    assert.equal(relevant.length, 1, 'linkAgentRuns issued no statement against agent_runs');
    assert.ok(
      relevant[0]?.sql.includes('"principal_user_id"'),
      `the ledger was stamped without naming an owner:\n${relevant[0]?.sql}`,
    );
    assert.ok(relevant[0]?.params.includes(ME));
    // Idempotent by predicate rather than by a read-then-write: a run that
    // already names a conversation is never re-pointed.
    assert.ok(relevant[0]?.sql.includes('is null'));
  });

  /**
   * Administration is not a key to somebody's chat history. Everywhere else in
   * PIG `isPlatformAdmin` widens what is visible; here it must bind the
   * administrator's own id like anyone else's, because the transcript is a
   * person's half-formed questions and the audit trail lives elsewhere.
   */
  it('gives a platform admin no way past the predicate', async () => {
    const adminId = '00000000-0000-4000-8000-0000000000dd';
    const admin = makePrincipal({ userId: adminId, isPlatformAdmin: true });
    for (const run of [
      (service: PiggyConversationService) => service.detail(admin, SOMEONE_ELSE_CONVERSATION),
      (service: PiggyConversationService) => service.rename(admin, SOMEONE_ELSE_CONVERSATION, 'x'),
      (service: PiggyConversationService) => service.remove(admin, SOMEONE_ELSE_CONVERSATION),
    ]) {
      const { db, statements } = recordingDatabase();
      await run(new PiggyConversationService(db));
      assertScopedTo(statements, adminId, 'platform admin');
      assert.ok(
        statements.every((s) => !s.params.includes(ME)),
        'a platform admin reached a conversation by naming its owner',
      );
    }
  });
});

// -------------------------------------------------------------------- routes

function conversationApp(principal = makePrincipal({ userId: ME })) {
  const { db, statements } = recordingDatabase();
  const app = new Hono<ApiEnv>();
  app.use('*', async (context, next) => {
    context.set('principal', principal);
    await next();
  });
  app.route('/', createPiggyConversationRoutes(db));
  // The app's own mapping, reproduced so a 403 here means a 403 there.
  app.onError((error, c) =>
    error instanceof AuthError
      ? c.json(apiError(error.code, error.message), error.status)
      : c.json({ error: 'Internal error' }, 500),
  );
  return { app, statements };
}

describe('the routes answer for the caller only', () => {
  for (const [method, path] of [
    ['GET', `/api/piggy/conversations/${SOMEONE_ELSE_CONVERSATION}`],
    ['PATCH', `/api/piggy/conversations/${SOMEONE_ELSE_CONVERSATION}`],
    ['DELETE', `/api/piggy/conversations/${SOMEONE_ELSE_CONVERSATION}`],
  ] as const) {
    it(`answers 404 to ${method} on somebody else's conversation`, async () => {
      const { app } = conversationApp();
      const response = await app.request(path, {
        method,
        ...(method === 'PATCH'
          ? { headers: { 'content-type': 'application/json' }, body: '{"title":"Mine now"}' }
          : {}),
      });
      assert.equal(response.status, 404);
      assert.equal(((await response.json()) as { code: string }).code, 'not_found');
    });
  }

  it('answers a malformed id without asking the database', async () => {
    const { app, statements } = conversationApp();
    const response = await app.request('/api/piggy/conversations/not-a-uuid');
    assert.equal(response.status, 404);
    // Postgres raises on a non-UUID parameter, which would surface as a 500 on
    // any mistyped URL. It never gets that far.
    assert.equal(statements.length, 0);
  });

  it('refuses a read-only credential every write', async () => {
    const readOnly = makePrincipal({ userId: ME, via: 'api_key', scopes: ['read'] });
    for (const [method, path] of [
      ['POST', '/api/piggy/conversations'],
      ['PATCH', `/api/piggy/conversations/${SOMEONE_ELSE_CONVERSATION}`],
      ['DELETE', `/api/piggy/conversations/${SOMEONE_ELSE_CONVERSATION}`],
    ] as const) {
      const { app, statements } = conversationApp(readOnly);
      const response = await app.request(path, {
        method,
        headers: { 'content-type': 'application/json' },
        body: method === 'DELETE' ? undefined : '{}',
      });
      assert.equal(response.status, 403, `${method} ${path}`);
      assert.equal(((await response.json()) as { code: string }).code, 'insufficient_scope');
      assert.equal(statements.length, 0, 'a refused write still reached the database');
    }
  });
});

// ------------------------------------------------------------------- cascade

/**
 * The cascade is a property of the schema, not of any code path, so it is
 * asserted against the SQL that creates it. Without it, deleting a
 * conversation would leave its messages behind — rows nobody can reach, still
 * holding whatever the transcript said about the book.
 */
describe('the migration', () => {
  const sql = readFileSync(
    join(import.meta.dirname, '..', '..', '..', 'packages', 'db', 'migrations', '0014_piggy_conversations.sql'),
    'utf8',
  );

  it('deletes a transcript with its conversation', () => {
    assert.match(
      sql,
      /ALTER TABLE "piggy_messages" ADD CONSTRAINT "piggy_messages_conversation_id_piggy_conversations_id_fk"[\s\S]*?ON DELETE cascade/,
    );
  });

  it('deletes a conversation with its owner', () => {
    assert.match(
      sql,
      /ALTER TABLE "piggy_conversations" ADD CONSTRAINT "piggy_conversations_user_id_users_id_fk"[\s\S]*?ON DELETE cascade/,
    );
  });

  it('keeps the spend when the conversation goes', () => {
    // Cost accounting outlives the thread: the credit was burned either way.
    assert.match(
      sql,
      /ALTER TABLE "agent_runs" ADD CONSTRAINT "agent_runs_piggy_conversation_id_piggy_conversations_id_fk"[\s\S]*?ON DELETE set null/,
    );
  });
});

// ------------------------------------------------------- against a real database

const testDatabaseUrl = process.env.PIG_TEST_DATABASE_URL;

describe(
  'against a real database',
  { skip: testDatabaseUrl ? false : 'set PIG_TEST_DATABASE_URL to a scratch database' },
  async () => {
    const { createDatabase, agentRuns, piggyConversations, piggyMessages, users } = await import('@pig/db');
    const db = createDatabase({ url: testDatabaseUrl ?? '', max: 2 });
    const service = new PiggyConversationService(db);

    const owner = { userId: '' };
    const stranger = { userId: '' };

    after(async () => {
      // Users cascade to their conversations, which cascade to their
      // messages; this is also the last assertion the suite makes.
      for (const id of [owner.userId, stranger.userId]) {
        if (id) await db.delete(users).where(eq(users.id, id));
      }
      await db.$client.end();
    });

    it('creates two people to be told apart', async () => {
      const [a] = await db
        .insert(users)
        .values({ email: `piggy-owner-${randomUUID()}@example.test`, name: 'Owner' })
        .returning();
      const [b] = await db
        .insert(users)
        .values({ email: `piggy-stranger-${randomUUID()}@example.test`, name: 'Stranger' })
        .returning();
      assert.ok(a && b);
      owner.userId = a.id;
      stranger.userId = b.id;
    });

    it('names a thread from its first message and keeps the transcript in order', async () => {
      const created = await service.create(owner, { context: { type: 'page', route: '/margin' } });
      assert.equal(created.title, PIGGY_UNTITLED);

      await service.appendMessage(owner, created.id, {
        role: 'user',
        content: 'What is our worst idle block this month?',
      });
      await service.appendMessage(owner, created.id, {
        role: 'tool',
        model: 'nvidia/nemotron-3-nano-30b-a3b',
        mode: 'confirm',
        tool: {
          callId: 'call_1',
          name: 'pig_get_idle_capacity',
          arguments: { thresholdPct: 0.15 },
          result: { worst: 'Northwind H100 block' },
          ok: true,
        },
        readCapability: 'economics:read',
      });
      await service.appendMessage(owner, created.id, {
        role: 'assistant',
        content: 'Northwind Robotics, at 38 per cent idle.',
        model: 'nvidia/nemotron-3-nano-30b-a3b',
        mode: 'confirm',
        inputTokens: 2_100,
        outputTokens: 180,
        costMicroCents: 4_200,
      });

      const detail = await service.detail(makePrincipal({ userId: owner.userId }), created.id);
      assert.ok(detail);
      // The title came from the first user message, not from the placeholder.
      assert.equal(detail.title, 'What is our worst idle block this month?');
      assert.deepEqual(
        detail.messages.map((message) => [message.seq, message.role]),
        [
          [0, 'user'],
          [1, 'tool'],
          [2, 'assistant'],
        ],
      );
      // The evidence survives the reload, which is the whole claim.
      assert.equal(detail.messages[1]?.tool?.name, 'pig_get_idle_capacity');
      assert.deepEqual(detail.messages[1]?.tool?.result, { worst: 'Northwind H100 block' });
      assert.equal(detail.messages[2]?.costMicroCents, 4_200);
      assert.equal(detail.model, 'nvidia/nemotron-3-nano-30b-a3b');

      await service.remove(owner, created.id);
    });

    it('keeps an approval card settled across a reload', async () => {
      const created = await service.create(owner, { firstMessage: 'Log a call on Northwind' });
      const change = {
        id: 'change_1',
        tool: 'pig_log_activity',
        kind: 'activity',
        summary: 'Log a call on Northwind Robotics',
        fields: [{ label: 'Subject', value: 'Chased the firm quote' }],
      };
      await service.appendMessage(owner, created.id, {
        role: 'tool',
        mode: 'confirm',
        tool: { callId: 'call_2', name: 'pig_log_activity', arguments: {}, ok: true },
        approval: { change, decision: 'apply', decidedAt: new Date() },
      });

      const detail = await service.detail(makePrincipal({ userId: owner.userId }), created.id);
      assert.equal(detail?.messages[0]?.approval?.decision, 'apply');
      assert.deepEqual(detail?.messages[0]?.approval?.change, change);

      await service.remove(owner, created.id);
    });

    it('hides a conversation from everyone but its owner', async () => {
      const created = await service.create(owner, { firstMessage: 'Private question' });
      await service.appendMessage(owner, created.id, { role: 'user', content: 'Private question' });

      const asStranger = makePrincipal({ userId: stranger.userId });
      const asAdmin = makePrincipal({ userId: stranger.userId, isPlatformAdmin: true });

      assert.equal(await service.detail(asStranger, created.id), null);
      assert.equal(await service.detail(asAdmin, created.id), null);
      assert.deepEqual(await service.promptHistory(asStranger, created.id), []);
      assert.equal(await service.rename(stranger, created.id, 'Mine now'), null);
      assert.equal(await service.remove(stranger, created.id), false);
      assert.equal(await service.appendMessage(stranger, created.id, { role: 'user', content: 'x' }), null);
      assert.deepEqual(await service.list(stranger), []);

      // Every refusal above left the conversation exactly as it was.
      const detail = await service.detail(makePrincipal({ userId: owner.userId }), created.id);
      assert.equal(detail?.title, 'Private question');
      assert.equal(detail?.messages.length, 1);

      await service.remove(owner, created.id);
    });

    it('refuses the transcript to its own author once they are demoted', async () => {
      const created = await service.create(owner, { firstMessage: 'What is our margin?' });
      await service.appendMessage(owner, created.id, {
        role: 'assistant',
        content: 'Gross margin is 31 per cent.',
        readCapability: 'economics:read',
      });

      const demoted = makePrincipal({
        userId: owner.userId,
        teams: [{ team: 'demand', role: 'viewer' }],
      });
      await assert.rejects(
        () => service.detail(demoted, created.id),
        (error: unknown) => error instanceof AuthError && error.status === 403,
      );
      await assert.rejects(
        () => service.promptHistory(demoted, created.id),
        (error: unknown) => error instanceof AuthError && error.status === 403,
      );
      assert.equal(await service.readCapabilityFor(owner, created.id), 'economics:read');

      await service.remove(owner, created.id);
    });

    it('deletes the messages with the conversation, and keeps the spend', async () => {
      const created = await service.create(owner, { firstMessage: 'Doomed thread' });
      await service.appendMessage(owner, created.id, { role: 'user', content: 'Doomed thread' });
      await service.appendMessage(owner, created.id, { role: 'assistant', content: 'Quite.' });

      const [run] = await db
        .insert(agentRuns)
        .values({
          agent: 'piggy',
          principalUserId: owner.userId,
          piggyConversationId: created.id,
          costMicroCents: 4_200,
        })
        .returning();
      assert.ok(run);

      assert.equal(await service.remove(owner, created.id), true);

      const orphans = await db
        .select()
        .from(piggyMessages)
        .where(eq(piggyMessages.conversationId, created.id));
      assert.equal(orphans.length, 0, 'messages outlived their conversation');

      // The run survives with its cost and loses only the link, because the
      // credit was spent whatever became of the thread.
      const [survivor] = await db.select().from(agentRuns).where(eq(agentRuns.id, run.id));
      assert.equal(survivor?.costMicroCents, 4_200);
      assert.equal(survivor?.piggyConversationId, null);
      await db.delete(agentRuns).where(eq(agentRuns.id, run.id));
    });

    /**
     * The whole of D2, at the layer that has to be true: a turn goes in as the
     * NDJSON the agent streamed, and comes back out as a transcript with its
     * evidence attached. Driven through `PiggyTurnRecorder` against a real
     * Postgres rather than through the relay, because what is in doubt here is
     * the storage — the relay's half is asserted in piggy-chat.test.ts.
     */
    it('reopens a streamed turn complete, with the records behind the answer', async () => {
      const created = await service.create(owner, { id: randomUUID() });
      const change = {
        id: 'change_9',
        tool: 'pig_log_activity',
        kind: 'activity',
        summary: 'Log a call on Northwind Robotics',
        fields: [{ label: 'Subject', value: 'Chased the firm quote' }],
      };
      const recorder = new PiggyTurnRecorder({
        store: service,
        owner,
        conversationId: created.id,
        mode: 'confirm',
        model: 'nvidia/nemotron-3-nano-30b-a3b',
        capability: 'economics:read',
      });
      recorder.question('What is our worst idle block this month?');
      const frames = [
        { type: 'meta', model: 'anthropic/claude-opus-5', mode: 'confirm', conversationId: created.id },
        { type: 'tool_call', id: 'call_9', name: 'pig_get_idle_capacity', arguments: { thresholdPct: 0.15 } },
        { type: 'tool_result', id: 'call_9', name: 'pig_get_idle_capacity', ok: true, result: { worst: 'Northwind H100 block' } },
        { type: 'approval_required', change },
        { type: 'approval_resolved', changeId: 'change_9', decision: 'apply', ok: true },
        { type: 'content_delta', delta: 'Northwind Robotics, at 38 per cent idle.' },
        { type: 'done', inputTokens: 2_100, outputTokens: 180, costMicroCents: 4_200 },
      ];
      const bytes = new TextEncoder().encode(frames.map((f) => `${JSON.stringify(f)}\n`).join(''));
      // Split mid-frame, as a socket would.
      recorder.absorb(bytes.slice(0, 137));
      recorder.absorb(bytes.slice(137));
      await recorder.finish();

      const detail = await service.detail(makePrincipal({ userId: owner.userId }), created.id);
      assert.ok(detail);
      assert.deepEqual(
        detail.messages.map((message) => [message.seq, message.role]),
        [
          [0, 'user'],
          [1, 'tool'],
          [2, 'tool'],
          [3, 'assistant'],
        ],
      );
      assert.equal(detail.messages[0]?.content, 'What is our worst idle block this month?');
      assert.equal(detail.messages[1]?.tool?.name, 'pig_get_idle_capacity');
      assert.deepEqual(detail.messages[1]?.tool?.result, { worst: 'Northwind H100 block' });
      assert.equal(detail.messages[2]?.approval?.decision, 'apply');
      assert.deepEqual(detail.messages[2]?.approval?.change, change);
      assert.equal(detail.messages[3]?.content, 'Northwind Robotics, at 38 per cent idle.');
      assert.equal(detail.messages[3]?.costMicroCents, 4_200);
      // Which model ANSWERED, from `meta` rather than from what was asked for.
      assert.equal(detail.model, 'anthropic/claude-opus-5');
      // The turn read the cost book, so the thread now needs that capability.
      assert.equal(await service.readCapabilityFor(owner, created.id), 'economics:read');
      // And the next turn replays the words without the payloads.
      assert.deepEqual(await service.promptHistory(makePrincipal({ userId: owner.userId }), created.id), [
        { role: 'user', content: 'What is our worst idle block this month?' },
        { role: 'assistant', content: 'Northwind Robotics, at 38 per cent idle.' },
      ]);

      await service.remove(owner, created.id);
    });

    it('opens a conversation under the id the turn is already running with', async () => {
      // The relay settles the id before the store is consulted, because an
      // approval posted mid-turn travels with it.
      const id = randomUUID();
      const created = await service.create(owner, { id, firstMessage: 'Keep my id' });
      assert.equal(created.id, id);
      // And it cannot be used to join a thread that is not the caller's: the
      // primary key refuses, which is what makes this safe to accept.
      await assert.rejects(() => service.create({ userId: stranger.userId }, { id }));
      await service.remove(owner, id);
    });

    it('points this thread’s spend at it, and nobody else’s', async () => {
      const mine = await service.create(owner, { firstMessage: 'What did this cost?' });
      const other = await service.create(owner, { firstMessage: 'A different thread' });

      const rows = await db
        .insert(agentRuns)
        .values([
          // The run this turn opened: stamped.
          { agent: 'piggy', principalUserId: owner.userId, input: { conversationId: mine.id }, costMicroCents: 4_200 },
          // A second turn in the same thread: also stamped, which is what makes
          // per-conversation spend one query rather than a JSON scan.
          { agent: 'piggy', principalUserId: owner.userId, input: { conversationId: mine.id }, costMicroCents: 1_100 },
          // Another thread of mine: untouched.
          { agent: 'piggy', principalUserId: owner.userId, input: { conversationId: other.id } },
          // Somebody else's run naming my conversation — the case the owner
          // predicate exists for, since the id travels through a browser.
          { agent: 'piggy', principalUserId: stranger.userId, input: { conversationId: mine.id } },
          // A queued task run, which carries no conversation at all.
          { agent: 'piggy', principalUserId: owner.userId, input: { surface: 'task' } },
        ])
        .returning({ id: agentRuns.id });
      assert.equal(rows.length, 5);

      await service.linkAgentRuns(owner, mine.id);

      const stamped = await db
        .select({ id: agentRuns.id, conversation: agentRuns.piggyConversationId })
        .from(agentRuns)
        .where(inArray(agentRuns.id, rows.map((row) => row.id)));
      // Keyed by id rather than compared positionally: an UPDATE rewrites the
      // rows it touched, and Postgres is under no obligation to hand them back
      // in insertion order afterwards.
      const byId = new Map(stamped.map((row) => [row.id, row.conversation]));
      assert.deepEqual(
        rows.map((row) => byId.get(row.id)),
        [mine.id, mine.id, null, null, null],
      );

      await db.delete(agentRuns).where(inArray(agentRuns.id, rows.map((row) => row.id)));
      await service.remove(owner, mine.id);
      await service.remove(owner, other.id);
    });

    it('takes every conversation with the person who owned it', async () => {
      const [doomed] = await db
        .insert(users)
        .values({ email: `piggy-doomed-${randomUUID()}@example.test`, name: 'Doomed' })
        .returning();
      assert.ok(doomed);
      const created = await service.create({ userId: doomed.id }, { firstMessage: 'Leaving' });
      await service.appendMessage({ userId: doomed.id }, created.id, {
        role: 'user',
        content: 'Leaving',
      });

      await db.delete(users).where(eq(users.id, doomed.id));

      const conversations = await db
        .select()
        .from(piggyConversations)
        .where(eq(piggyConversations.id, created.id));
      assert.equal(conversations.length, 0);
      const messages = await db
        .select()
        .from(piggyMessages)
        .where(eq(piggyMessages.conversationId, created.id));
      assert.equal(messages.length, 0);
    });
  },
);
