import { randomUUID } from 'node:crypto';
import {
  PIGGY_MODES,
  PIGGY_PAGE_ROUTES,
  PIGGY_RECORD_TYPES,
  permissionGranted,
  resolveReadPermissionGrants,
  resolveWritePermissionGrants,
} from '@pig/core';
import type {
  PiggyApprovalDecision,
  PiggyMode,
  PiggyModelOption,
  ReadCapability,
  WriteCapability,
} from '@pig/core';
import type { Database } from '@pig/db';
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { z } from 'zod';
import type { Config } from '../lib/config';
import type { Principal } from '../lib/auth';
import { apiError, type ApiEnv } from '../lib/mutation';
import {
  PIGGY_PROMPT_HISTORY_LIMIT,
  PiggyTurnRecorder,
  type PiggyConversationOwner,
  type PiggyTranscriptStore,
} from '../services/piggy-conversations';
import { ensurePlatformSettings, probePiggyChatServer } from './admin-settings';
import { createAttemptLimiter, type AttemptLimiter } from './learn';
import { piggyContextCapability } from './read-guards';

/**
 * Derived from the @pig/core tuples, and kept in step with the identical
 * schema in the Piggy chat server. Both are `.strict()`, so a context arm
 * missing from either one is a 400 at that hop rather than a degraded answer.
 */
const contextSchema = z.discriminatedUnion('type', [
  z
    .object({
      type: z.enum(PIGGY_RECORD_TYPES),
      id: z.string().uuid(),
      label: z.string().max(240).optional(),
    })
    .strict(),
  z
    .object({
      type: z.literal('page'),
      route: z.enum(PIGGY_PAGE_ROUTES),
      label: z.string().max(240).optional(),
    })
    .strict(),
]);

/**
 * The mode a turn runs in when the request names none.
 *
 * Deliberately the least privileged of the three rather than the deployment's
 * preference: an older client, a field dropped by an intermediary or a body
 * assembled by hand must not be a way for write tools to appear. Turning them
 * on has to be something the caller said explicitly.
 */
export const PIGGY_DEFAULT_MODE: PiggyMode = 'read_only';

/**
 * The floor a write mode needs before the harness is even offered write tools.
 *
 * It is a floor and not the whole authorisation: each mutation runs through
 * `executeMutation` as this principal, which checks the capability that
 * particular write requires. What this catches is the case that never reaches a
 * mutation — a viewer, or a read-scoped API key, switching the mode to `auto`
 * and having Piggy compose writes it will only be refused at the last hop,
 * after the tokens have been spent and the model has been told it can save.
 */
const PIGGY_WRITE_FLOOR: WriteCapability = 'activity:write';

/** Spelled as a tuple so the schema and the contract's union cannot drift. */
const APPROVAL_DECISIONS = ['apply', 'reject'] as const satisfies readonly PiggyApprovalDecision[];

/**
 * The longest replayed turn the agent's own schema will accept.
 *
 * Spelled here because the relay now BUILDS the history rather than forwarding
 * the client's, and a stored answer is under no obligation to be short: a
 * margin summary with a table in it runs past this easily, and forwarding it
 * whole would 400 the turn at the agent with nothing in the browser to explain
 * why the same question worked yesterday.
 */
const PIGGY_HISTORY_CONTENT_MAX = 8_000;

const requestSchema = z
  .object({
    message: z.string().trim().min(1).max(4_000),
    /**
     * Accepted, and used only when the transcript store cannot answer. The
     * server's own copy is the truth: this one is capped at twenty turns by a
     * client that can be made to send anything, and a resumed thread must not
     * depend on what the browser happens to still be holding.
     */
    history: z
      .array(
        z.object({
          role: z.enum(['user', 'assistant']),
          content: z.string().min(1).max(PIGGY_HISTORY_CONTENT_MAX),
        }),
      )
      .max(PIGGY_PROMPT_HISTORY_LIMIT)
      .optional(),
    context: contextSchema.optional(),
    mode: z.enum(PIGGY_MODES).default(PIGGY_DEFAULT_MODE),
    /**
     * Checked against the agent's own catalogue below, never forwarded on the
     * caller's word. The harness will load whatever id it is handed, so an
     * unchecked one here is a way to bill the company's inference credit
     * against a model nobody chose.
     */
    modelId: z.string().trim().min(1).max(200).optional(),
    conversationId: z.string().uuid().optional(),
  })
  .strict();

const approveSchema = z
  .object({
    conversationId: z.string().uuid(),
    changeId: z.string().min(1).max(200),
    decision: z.enum(APPROVAL_DECISIONS),
  })
  .strict();

/**
 * One entry of the catalogue as the agent serves it.
 *
 * Not `.strict()`, unlike everything else here, and the asymmetry is on
 * purpose: the request schemas are strict because an unexpected field there is
 * a misunderstanding about authority, whereas this is a list we forward to a
 * picker. A field the agent adds ahead of the relay knowing about it should
 * reach the browser, not 502 the whole catalogue.
 */
const modelOptionSchema = z.object({
  id: z.string().min(1),
  label: z.string().min(1),
  hint: z.string().optional(),
  costPerMTokIn: z.number(),
  costPerMTokOut: z.number(),
  contextWindow: z.number().int().positive(),
  reasoning: z.boolean(),
  isDefault: z.boolean().optional(),
});

/**
 * `GET /internal/models` answers with the bare array. The wrapped form is
 * accepted as well because that is the shape this relay serves onward to the
 * browser, and the two hops were written in parallel — a catalogue that reads
 * either way cannot leave the picker empty over a disagreement about one key,
 * which presents as a permanent 503 with nothing in any log to explain it.
 */
const modelCatalogueSchema = z.union([
  z.array(modelOptionSchema).min(1),
  z
    .object({ models: z.array(modelOptionSchema).min(1) })
    .transform((wrapper) => wrapper.models),
]);

/**
 * The whole product runs on a fixed Prime Intellect credit, so the quota that
 * matters is per person and per hour, not per second. Thirty is roughly a
 * working session's worth of questions: nobody who is using Piggy notices it,
 * and a runaway client burns an hour's allowance rather than the balance.
 */
export const PIGGY_MESSAGES_PER_HOUR = 30;
const PIGGY_RATE_WINDOW_MS = 60 * 60 * 1_000;

/**
 * How long a health probe is believed. Short enough that restarting the Piggy
 * service un-greys the dock within a page refresh or two, long enough that a
 * dock on every page does not turn `/api/piggy/status` into a loopback flood.
 */
const PIGGY_HEALTH_CACHE_MS = 10_000;

/**
 * How long the model catalogue is believed.
 *
 * It changes when the agent is redeployed, so a minute is the difference
 * between a picker that lists a new model promptly and a status call that
 * fetches the list on every navigation.
 */
const PIGGY_MODELS_CACHE_MS = 60_000;

/**
 * How long the relay remembers who owns a conversation.
 *
 * Longer than any turn, shorter than a working day: the map exists to answer
 * "may this person approve this pending write?", and a pending write that has
 * sat unanswered for twelve hours has already timed out at the agent.
 */
const CONVERSATION_OWNER_TTL_MS = 12 * 60 * 60 * 1_000;
/** A ceiling so a busy day cannot turn the map into a leak. */
const CONVERSATION_OWNER_LIMIT = 5_000;

export interface PiggyChatProxyOptions {
  enabled: boolean;
  internalUrl?: string;
  internalToken?: string;
  fetchImpl?: typeof fetch;
  /**
   * Where the turn is written down.
   *
   * Required rather than optional, and that is the whole point of the option:
   * an optional store is one a composition can forget, and forgetting it is
   * precisely what shipped — `appendMessage` was written, tested and called by
   * nothing, so twelve conversations on the dev database held zero messages
   * between them. A required dependency makes that a compile error.
   */
  conversations: PiggyTranscriptStore;
  /**
   * The admin toggle, read per request. Omitted, the environment gate alone
   * decides — which is what shipped, and why turning Piggy off in the admin UI
   * did nothing.
   */
  resolvePiggyEnabled?: () => Promise<boolean>;
  /** Messages per user per hour. Defaults to `PIGGY_MESSAGES_PER_HOUR`. */
  messagesPerHour?: number;
  /** Injected by the tests so a quota can be exhausted without waiting. */
  limiter?: AttemptLimiter;
  healthCacheMs?: number;
  modelsCacheMs?: number;
}

/** The stored toggle. Paired with `createPiggyChatRoutes` at composition. */
export function platformPiggyEnabled(config: Config, db: Database): () => Promise<boolean> {
  return async () => (await ensurePlatformSettings(config, db)).piggyEnabled;
}

export function createPiggyChatRoutes(options: PiggyChatProxyOptions) {
  const routes = new Hono<ApiEnv>();
  const fetchImpl = options.fetchImpl ?? fetch;
  // Configuration cannot change under a running process; the toggle can.
  const configured = Boolean(options.enabled && options.internalUrl && options.internalToken);
  const base = options.internalUrl?.replace(/\/$/, '') ?? '';
  const healthCacheMs = options.healthCacheMs ?? PIGGY_HEALTH_CACHE_MS;
  const modelsCacheMs = options.modelsCacheMs ?? PIGGY_MODELS_CACHE_MS;
  const limiter =
    options.limiter ??
    createAttemptLimiter({
      limit: options.messagesPerHour ?? PIGGY_MESSAGES_PER_HOUR,
      windowMs: PIGGY_RATE_WINDOW_MS,
    });

  // ------------------------------------------------------------------ health

  let healthy = false;
  let checkedAt = 0;
  /** One probe at a time: a dock on every page opens a burst of status calls. */
  let inFlight: Promise<boolean> | null = null;

  /**
   * The same probe the settings panel runs, so a dead Piggy cannot be reported
   * dead on one screen and alive on the other. Only the caching differs, and it
   * differs on purpose — see `chatServerHealthy` below.
   */
  async function probe(): Promise<boolean> {
    return (await probePiggyChatServer(base, fetchImpl)).ok;
  }

  function remember(result: boolean): boolean {
    healthy = result;
    checkedAt = Date.now();
    return result;
  }

  /**
   * Is the chat server actually answering?
   *
   * The reason this exists: `configured` tests environment variables, which
   * are equally true when the Piggy process is dead or has no inference key.
   * `/api/piggy/status` therefore reported `canUse: true` and the dock drew a
   * live composer over a service that could not answer, and the first message
   * came back as a red "Internal error" bubble. A probe makes the status
   * honest, so the dock shows its own "Piggy is unavailable" state instead.
   */
  async function chatServerHealthy(): Promise<boolean> {
    if (Date.now() - checkedAt < healthCacheMs) return healthy;
    inFlight ??= probe()
      .then(remember)
      .finally(() => {
        inFlight = null;
      });
    return inFlight;
  }

  /**
   * The environment variable is the outer gate, the stored setting the inner
   * one, and the probe the last word: an operator who has not provisioned
   * Piggy cannot have it switched on from the admin UI, and an operator who
   * has cannot be told it works when the process is down. A failed settings
   * read falls through to the probe rather than 503-ing every dock on the site
   * over one bad query.
   */
  async function isAvailable(): Promise<boolean> {
    if (!configured) return false;
    if (options.resolvePiggyEnabled) {
      try {
        if (!(await options.resolvePiggyEnabled())) return false;
      } catch {
        // Deliberately not a denial — see above.
      }
    }
    return chatServerHealthy();
  }

  // -------------------------------------------------------------- catalogue

  let catalogue: PiggyModelOption[] | null = null;
  let catalogueAt = 0;
  let catalogueInFlight: Promise<PiggyModelOption[] | null> | null = null;

  async function fetchCatalogue(): Promise<PiggyModelOption[] | null> {
    try {
      const response = await fetchImpl(`${base}/internal/models`, {
        headers: {
          authorization: `Bearer ${options.internalToken ?? ''}`,
          accept: 'application/json',
        },
      });
      if (!response.ok) return null;
      const parsed = modelCatalogueSchema.safeParse(await response.json());
      if (!parsed.success) return null;
      catalogue = parsed.data;
      catalogueAt = Date.now();
      return catalogue;
    } catch {
      return null;
    }
  }

  /**
   * The models the agent will actually accept, or null when it cannot say.
   *
   * A failure is not cached. The alternative — remembering "no catalogue" for a
   * minute — would keep the picker empty and every named model refused for a
   * minute after the agent came back up, which is the same dishonesty the
   * health probe exists to prevent, only slower to notice.
   */
  async function loadCatalogue(): Promise<PiggyModelOption[] | null> {
    if (catalogue && Date.now() - catalogueAt < modelsCacheMs) return catalogue;
    catalogueInFlight ??= fetchCatalogue().finally(() => {
      catalogueInFlight = null;
    });
    return catalogueInFlight;
  }

  function defaultModelId(models: PiggyModelOption[]): string | null {
    return models.find((model) => model.isDefault)?.id ?? models[0]?.id ?? null;
  }

  // ---------------------------------------------------------- conversations

  /**
   * Who opened each conversation, so an approval can be checked against it.
   *
   * The relay is the only hop that has both the signed-in principal and the
   * conversation id, so ownership is recorded here at the moment a turn is
   * authorised. Without it `POST /api/piggy/approve` would be a way for any
   * member to apply somebody else's pending write, since a change id is the
   * only other thing that call carries.
   *
   * In memory on purpose: it answers a question about turns that are still
   * open, and a relay restart has already broken every stream those turns were
   * being written to.
   */
  const conversationOwners = new Map<string, { userId: string; touchedAt: number }>();

  function pruneConversations(now: number): void {
    for (const [id, owner] of conversationOwners) {
      if (now - owner.touchedAt > CONVERSATION_OWNER_TTL_MS) conversationOwners.delete(id);
    }
    // Insertion order is least-recently-claimed first, because every claim
    // re-inserts. Trimming from the front therefore drops the coldest.
    while (conversationOwners.size > CONVERSATION_OWNER_LIMIT) {
      const oldest = conversationOwners.keys().next();
      if (oldest.done) break;
      conversationOwners.delete(oldest.value);
    }
  }

  /** False when the id is already someone else's — never silently re-owned. */
  function claimConversation(id: string, userId: string): boolean {
    const now = Date.now();
    const owner = conversationOwners.get(id);
    if (owner && owner.userId !== userId && now - owner.touchedAt <= CONVERSATION_OWNER_TTL_MS) {
      return false;
    }
    conversationOwners.delete(id);
    conversationOwners.set(id, { userId, touchedAt: now });
    pruneConversations(now);
    return true;
  }

  function ownsConversation(id: string, userId: string): boolean {
    const owner = conversationOwners.get(id);
    return Boolean(
      owner && owner.userId === userId && Date.now() - owner.touchedAt <= CONVERSATION_OWNER_TTL_MS,
    );
  }

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

  routes.get('/api/piggy/status', async (c) => {
    const principal = c.get('principal');
    const available = await isAvailable();
    const models = available ? await loadCatalogue() : null;
    return c.json({
      enabled: available,
      /**
       * The floor, not the whole authorisation: the capability a turn needs
       * depends on the context it carries, which is not knowable here. Saying
       * `true` to someone who holds no read capability at all would still be a
       * composer that can only 403, so the floor is worth checking.
       */
      canUse: available && holdsReadCapability(principal, 'book:read'),
      /**
       * What a client that has stored no preference should open in. The mode is
       * the safe one for everybody; the model is whichever the deployment
       * marked default, and null when the agent cannot be asked — a picker with
       * nothing in it is better than one showing a model that would be refused.
       */
      mode: PIGGY_DEFAULT_MODE,
      modelId: models ? defaultModelId(models) : null,
    });
  });

  routes.get('/api/piggy/models', async (c) => {
    const principal = c.get('principal');
    // Gated here rather than in READ_RULES because the catalogue is not book
    // data — it is prices and context windows — but it is still nobody's
    // business but a member's, and offering the picker to someone whose every
    // turn would 403 is a menu of doors that do not open.
    if (!holdsReadCapability(principal, 'book:read')) {
      return c.json(
        apiError('insufficient_permission', "This principal lacks the 'book:read' capability."),
        403,
      );
    }
    if (!(await isAvailable())) {
      return c.json(apiError('piggy_unavailable', 'Piggy chat is not available.'), 503);
    }
    const models = await loadCatalogue();
    if (!models) {
      return c.json(apiError('piggy_unavailable', 'Piggy chat is not available.'), 503);
    }
    return c.json({ models, defaultModelId: defaultModelId(models) });
  });

  routes.post('/api/piggy/chat', async (c) => {
    const principal = c.get('principal');
    if (!principal.scopes.includes('read')) {
      return c.json(apiError('insufficient_scope', "This credential lacks the 'read' scope."), 403);
    }
    if (!(await isAvailable()) || !options.internalUrl || !options.internalToken) {
      return c.json(apiError('piggy_unavailable', 'Piggy chat is not available.'), 503);
    }

    let raw: unknown;
    try {
      raw = await c.req.json();
    } catch {
      return c.json(apiError('invalid_json', 'Request body must be valid JSON.'), 400);
    }
    const parsed = requestSchema.safeParse(raw);
    if (!parsed.success) {
      return c.json(
        apiError('invalid_request', 'Invalid Piggy chat request.', parsed.error.issues),
        400,
      );
    }
    const { mode, modelId, conversationId: requestedConversationId, ...turn } = parsed.data;

    /**
     * Authorised here and nowhere else. The chat server builds its tools from
     * the context and the mode; the capability lives on `Principal.teams`, and
     * although the full principal now crosses the hop, the relay is where the
     * refusal belongs — before a turn is opened, a run row is written or a
     * token is spent.
     */
    const capability = piggyContextCapability(turn.context);
    if (!holdsReadCapability(principal, capability)) {
      return c.json(
        apiError(
          'insufficient_permission',
          `This principal lacks the '${capability}' capability.`,
        ),
        403,
      );
    }

    /**
     * A mode above `read_only` is a request for write tools, so it is checked
     * as a write. `read_only` is left alone: it offers the model no write tool
     * at all, which is a stronger guarantee than offering one and refusing it.
     */
    if (mode !== 'read_only' && !holdsWriteCapability(principal, PIGGY_WRITE_FLOOR)) {
      return c.json(
        apiError(
          'insufficient_permission',
          `This principal lacks the '${PIGGY_WRITE_FLOOR}' capability, so Piggy can only read.`,
        ),
        403,
      );
    }

    if (modelId) {
      const models = await loadCatalogue();
      if (!models) {
        // The model cannot be checked, so it cannot be forwarded. Falling back
        // to the default silently would answer in a model the user did not ask
        // for and charge them for it.
        return c.json(apiError('piggy_unavailable', 'Piggy chat is not available.'), 503);
      }
      if (!models.some((model) => model.id === modelId)) {
        return c.json(
          apiError('invalid_model', 'That model is not one Piggy offers.'),
          400,
        );
      }
    }

    /**
     * Counted after authorisation, so a caller who is being refused does not
     * spend the quota they were never going to use, and immediately before the
     * hop, so nothing that reaches inference is uncounted. Keyed on the user
     * rather than the address: the credit is spent per person, and everyone
     * behind the office NAT shares an address.
     */
    const decision = limiter.check(principal.userId);
    if (!decision.allowed) {
      c.header('retry-after', String(decision.retryAfterSeconds));
      return c.json(
        {
          ...apiError(
            'piggy_rate_limited',
            // No "try again shortly": the client turns `retryAfterSeconds` into
            // a wall-clock time and says exactly when Retry comes back, so a
            // vaguer version of the same promise here would only contradict it.
            "You have used this hour's Piggy questions.",
          ),
          retryAfterSeconds: decision.retryAfterSeconds,
        },
        429,
      );
    }

    /**
     * Minted here when the client has none, so that every conversation the
     * agent sees is one this relay authorised and recorded an owner for. The
     * client learns it from the `meta` event the agent echoes back.
     *
     * Settled BEFORE the store is consulted and never changed afterwards. An
     * approval posted mid-turn travels with this id, so a relay that quietly
     * substituted the store's own would strand the card the user is answering.
     * It is also the cheapest refusal there is: a hijack attempt is turned away
     * without the database being asked anything at all.
     */
    const conversationId = requestedConversationId ?? randomUUID();
    if (!claimConversation(conversationId, principal.userId)) {
      return c.json(
        apiError('piggy_conversation_denied', 'That conversation belongs to someone else.'),
        403,
      );
    }

    const owner: PiggyConversationOwner = { userId: principal.userId };

    /**
     * Resume the thread if the store has it, and open it if it does not.
     *
     * Resuming goes through the store rather than being taken on the client's
     * word, and that is a capability check as much as an ownership one:
     * `readCapabilityFor` answers with what this conversation was TOLD, and a
     * member demoted out of `economics:read` must not be able to have
     * yesterday's margin answer replayed into a fresh prompt and read back to
     * them by the model. `detail` and `promptHistory` enforce the same gate on
     * the read side; this is the one on the write side.
     *
     * `recorded` is what everything below turns on: null means this turn is
     * happening but is not being written down. A database that is down should
     * cost somebody their history, never their answer.
     */
    let recorded: string | null = null;
    const told = requestedConversationId
      ? await tolerate('could not read a conversation', () =>
          options.conversations.readCapabilityFor(owner, conversationId),
        )
      : null;
    if (told) {
      if (!holdsReadCapability(principal, told)) {
        return c.json(
          apiError(
            'insufficient_permission',
            `This conversation needs the '${told}' capability, which this principal lacks.`,
          ),
          403,
        );
      }
      recorded = conversationId;
    } else {
      /*
       * Opened under the id the turn is already running with — including the
       * one the client sent for a thread the store has never seen, which is
       * what a dock conversation and a turn sent while the history endpoint
       * was failing both look like. An id that is somebody else's collides on
       * the primary key and fails the insert, so this cannot write into a
       * thread that is not the caller's.
       */
      const opened = await tolerate('could not open a conversation', () =>
        options.conversations.create(owner, {
          id: conversationId,
          firstMessage: turn.message,
          model: modelId ?? null,
          mode,
          context: turn.context ?? null,
          readCapability: capability,
        }),
      );
      recorded = opened?.id ?? null;
    }

    /**
     * What the model is told was said before.
     *
     * Built from the stored transcript, never from the client's copy: that copy
     * is capped at twenty turns by a browser, dropped by every reload, and
     * assembled by code the user can edit. The client's version survives only
     * as the fallback for a turn the store could not record, where it is the
     * sole remaining continuity and can disclose nothing its own author did not
     * already have.
     */
    let history = turn.history;
    if (recorded) {
      const replayed = await tolerate('could not replay a conversation', () =>
        options.conversations.promptHistory(principal, conversationId, PIGGY_PROMPT_HISTORY_LIMIT),
      );
      if (replayed) history = clampHistory(replayed);
    }

    /**
     * The turn is written down from here on. Created after the last refusal
     * above, so a question that was never asked is never filed, and before the
     * hop, so a question the agent never accepts still lands in the thread with
     * its failure underneath it.
     */
    const recorder = recorded
      ? new PiggyTurnRecorder({
          store: options.conversations,
          owner,
          conversationId,
          mode,
          model: modelId ?? null,
          capability,
        })
      : null;
    recorder?.question(turn.message);

    let upstream: Response;
    try {
      upstream = await fetchImpl(`${base}/internal/chat`, {
        method: 'POST',
        headers: {
          authorization: `Bearer ${options.internalToken}`,
          'content-type': 'application/json',
          accept: 'application/x-ndjson',
        },
        /**
         * The whole principal, not a user id. Piggy's write tools run through
         * `executeMutation` as the calling user, and a mutation needs the
         * memberships and scopes to check the capability it requires — a bare
         * id would leave the agent either fabricating a principal or writing
         * with more authority than the person who asked. The hop is loopback
         * and carries a timing-safe bearer token, which is what makes sending
         * identity over it acceptable.
         */
        body: JSON.stringify({ principal, conversationId, mode, modelId, ...turn, history }),
        signal: c.req.raw.signal,
      });
    } catch {
      /*
       * ECONNREFUSED used to travel all the way to `app.onError` and render as
       * a red "Internal error" bubble, which reads as "Piggy broke on your
       * question" rather than "Piggy is not running". A client abort lands
       * here too — nobody is reading that response, but marking the service
       * down over it would grey out the dock for everyone for ten seconds, so
       * only a genuine transport failure invalidates the health cache.
       */
      if (!c.req.raw.signal.aborted) remember(false);
      /*
       * The question is already filed; this is what happened to it. Without
       * it the thread reopens showing a question with no answer and no reason,
       * which reads as Piggy having ignored it.
       */
      recorder?.fail('Piggy chat is not available.');
      await recorder?.finish();
      return c.json(apiError('piggy_unavailable', 'Piggy chat is not available.'), 503);
    }

    if (!upstream.ok) {
      await upstream.body?.cancel().catch(() => {});
      recorder?.fail('Piggy chat service did not respond.');
      await recorder?.finish();
      return c.json(
        apiError('piggy_upstream_error', 'Piggy chat service did not respond.'),
        502,
      );
    }
    const upstreamBody = upstream.body;
    if (!upstreamBody) {
      recorder?.fail('Piggy chat service returned no response stream.');
      await recorder?.finish();
      return c.json(
        apiError('piggy_upstream_error', 'Piggy chat service returned no response stream.'),
        502,
      );
    }

    c.header('content-type', 'application/x-ndjson; charset=utf-8');
    c.header('cache-control', 'no-cache, no-transform');
    c.header('x-accel-buffering', 'no');
    return stream(c, async (output) => {
      const reader = upstreamBody.getReader();
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) return;
          /*
           * Read into the transcript BEFORE it is written onward. The recorder
           * cannot throw and the browser gets the same bytes either way, but a
           * reader that hangs up mid-write leaves the frame recorded rather
           * than lost — and a tool result the user never saw is still evidence
           * of what Piggy did to the book.
           */
          recorder?.absorb(value);
          await output.write(value);
        }
      } finally {
        reader.releaseLock();
        if (recorder) {
          await recorder.finish();
          /*
           * Now that the conversation certainly exists, point the run at it.
           * `agent_runs.piggy_conversation_id` is a foreign key, so this has to
           * follow the transcript rather than race it, and it is what makes
           * "what has this thread cost?" one indexed query.
           */
          await tolerate('could not link a turn to its conversation', () =>
            options.conversations.linkAgentRuns(owner, conversationId),
          );
        }
      }
    });
  });

  /**
   * The other half of a mid-turn approval.
   *
   * NDJSON is one-way, so the answer to an `approval_required` event cannot
   * travel back up the stream it arrived on. It comes in here instead, and the
   * agent resolves the promise the paused tool is waiting on; the outcome
   * reaches the user as an `approval_resolved` event on the still-open turn.
   * This endpoint therefore says only whether the decision was delivered — it
   * is not where the write is reported, because the write has not happened yet
   * when it answers.
   */
  routes.post('/api/piggy/approve', async (c) => {
    const principal = c.get('principal');
    if (!(await isAvailable()) || !options.internalUrl || !options.internalToken) {
      return c.json(apiError('piggy_unavailable', 'Piggy chat is not available.'), 503);
    }

    let raw: unknown;
    try {
      raw = await c.req.json();
    } catch {
      return c.json(apiError('invalid_json', 'Request body must be valid JSON.'), 400);
    }
    const parsed = approveSchema.safeParse(raw);
    if (!parsed.success) {
      return c.json(
        apiError('invalid_request', 'Invalid Piggy approval.', parsed.error.issues),
        400,
      );
    }

    // Approving IS the write, so it needs the same floor the mode did. Checked
    // again rather than trusted from the turn that raised it: the turn was
    // authorised minutes ago and a membership can be revoked in between.
    if (!holdsWriteCapability(principal, PIGGY_WRITE_FLOOR)) {
      return c.json(
        apiError(
          'insufficient_permission',
          `This principal lacks the '${PIGGY_WRITE_FLOOR}' capability.`,
        ),
        403,
      );
    }
    if (!ownsConversation(parsed.data.conversationId, principal.userId)) {
      return c.json(
        apiError('piggy_conversation_denied', 'That conversation is not yours to answer.'),
        403,
      );
    }

    let upstream: Response;
    try {
      upstream = await fetchImpl(`${base}/internal/approve`, {
        method: 'POST',
        headers: {
          authorization: `Bearer ${options.internalToken}`,
          'content-type': 'application/json',
          accept: 'application/json',
        },
        /**
         * The decision alone. No principal rides along, and it would be
         * refused if it did: the agent applies the change as the principal the
         * turn was opened with, and this endpoint has just established that the
         * person answering is that same person.
         */
        body: JSON.stringify(parsed.data),
        signal: c.req.raw.signal,
      });
    } catch {
      if (!c.req.raw.signal.aborted) remember(false);
      return c.json(apiError('piggy_unavailable', 'Piggy chat is not available.'), 503);
    }

    if (!upstream.ok) {
      await upstream.body?.cancel().catch(() => {});
      /**
       * A 404 is the ordinary end of a pending change rather than a fault: the
       * five-minute timeout has already rejected it, or the turn was aborted.
       * Reporting that as a server error would have the card offer a retry for
       * a decision that can never be delivered.
       */
      if (upstream.status === 404) {
        return c.json(
          apiError('approval_not_pending', 'That change is no longer waiting for an answer.'),
          404,
        );
      }
      return c.json(
        apiError('piggy_upstream_error', 'Piggy did not accept that decision.'),
        502,
      );
    }
    await upstream.body?.cancel().catch(() => {});
    return c.json({ ok: true, changeId: parsed.data.changeId, decision: parsed.data.decision });
  });

  return routes;
}

/**
 * Run a persistence step, and let it fail.
 *
 * Every call to the transcript store goes through here, which is the rule that
 * matters most in this file: **the answer is what the user came for**. A turn
 * that cannot be filed is a turn with no history, not a turn that 500s, and the
 * failure belongs in the operator's log rather than in the stream. Null is the
 * one signal it returns, and every caller reads it as "unrecorded".
 */
async function tolerate<T>(what: string, work: () => Promise<T>): Promise<T | null> {
  try {
    return await work();
  } catch (error) {
    console.error(`[piggy] ${what}:`, error);
    return null;
  }
}

/**
 * The stored transcript, cut to what the agent's schema will accept.
 *
 * Only the length is touched, and only at the tail: an answer trimmed mid-word
 * is worse context than a whole one and better context than a 400. The turn
 * count is already bounded by `PIGGY_PROMPT_HISTORY_LIMIT`, which is the same
 * twenty the agent enforces.
 */
function clampHistory(
  turns: { role: 'user' | 'assistant'; content: string }[],
): { role: 'user' | 'assistant'; content: string }[] {
  return turns.map((entry) => ({
    role: entry.role,
    content:
      entry.content.length > PIGGY_HISTORY_CONTENT_MAX
        ? entry.content.slice(0, PIGGY_HISTORY_CONTENT_MAX)
        : entry.content,
  }));
}

/**
 * `requireReadCapability` in the same shape, but returning rather than
 * throwing. These routes answer with `c.json` and are mounted in tests without
 * the app's `onError`, so an AuthError here would surface as a 500 in exactly
 * the place a 403 is being asserted.
 */
function holdsReadCapability(principal: Principal, capability: ReadCapability): boolean {
  return (
    principal.scopes.includes('read') &&
    permissionGranted(resolveReadPermissionGrants(principal), capability)
  );
}

/**
 * The same, for the write side.
 *
 * The scope check is not redundant with the grant check: a read-scoped API key
 * belonging to a demand lead resolves every write grant that person holds, and
 * only the scope says the credential itself was never meant to write.
 */
function holdsWriteCapability(principal: Principal, capability: WriteCapability): boolean {
  return (
    principal.scopes.includes('write') &&
    permissionGranted(resolveWritePermissionGrants(principal), capability)
  );
}
