/**
 * Learn — the member curriculum, the admin CRUD, and the one door in this API
 * that opens without a principal.
 *
 * ## The security shape, which is the reason this file is long
 *
 * Everywhere else in PIG a request resolves a `Principal` and then a
 * capability check decides what it may do. A code-holder has no account, so
 * there is no principal to resolve — and the tempting shortcut, minting a
 * synthetic one, is the thing this design exists to refuse. A principal is
 * accepted by every downstream handler by construction; the only thing keeping
 * it out of the CRM would be that each of those handlers remembered to check a
 * capability. One that forgot would leak the book of business to anyone
 * holding a marketing share code, and nothing would report an error.
 *
 * So the code mints a **scoped bearer token that is not a credential for this
 * API at all**. It is an HMAC over a scope string and an expiry, verified by
 * exactly one handler, and `authenticate()` never sees it. Presenting it to
 * `/api/dashboard` produces the same 401 as presenting nothing, because to the
 * authenticator it is simply a bearer token that is not a JWT and does not
 * start with `pig_`. There is a test that asserts precisely this.
 *
 * The token's signing key is derived from the stored access code, so rotating
 * the code invalidates every outstanding token as a side effect rather than
 * requiring a second revocation mechanism.
 *
 * ## The public read
 *
 * `GET /api/learn/public` is written so that it is *structurally* incapable of
 * naming another row: the predicates are two literals, there is no parameter
 * that reaches the WHERE clause, and the selected columns are enumerated. It
 * cannot be widened by a query string because it does not read one.
 *
 * ## What may be code-visible
 *
 * Only the platform track. Enforced here in the write path AND by a CHECK
 * constraint on the table. Not in the UI: a form is not a security boundary,
 * and a concept video becoming anon-visible through a mis-set select is the
 * failure that matters.
 */
import { and, asc, desc, eq, isNull } from 'drizzle-orm';
import { Hono } from 'hono';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import {
  LEARN_CODE_TRACK,
  LEARN_EMBED_REJECTION_MESSAGES,
  LEARN_TRACKS,
  LEARN_VISIBILITIES,
  learnEmbed,
  learnVisibilityPermitted,
  learnWatchUrl,
  resolveLearnEmbed,
  type LearnProvider,
  type LearnTrack,
  type LearnVisibility,
} from '@pig/core';
import { learnResources, platformSettings } from '@pig/db';
import type { Database } from '@pig/db';
import { requireCapability, safeEqual } from '../lib/auth';
import {
  apiError,
  bodylessMutation,
  MutationError,
  mutation,
  type ApiEnv,
} from '../lib/mutation';

/**
 * The two paths that must be allowlisted in `app.ts`, exported as constants
 * for the same reason the Slack and Notion callbacks are: a public path
 * spelled twice is a public path that eventually differs in one of them.
 */
export const LEARN_ACCESS_PATH = '/api/learn/access';
export const LEARN_PUBLIC_PATH = '/api/learn/public';

const SETTINGS_ID = 'default';

// ---------------------------------------------------------------------- token

const LEARN_TOKEN_VERSION = 'v1';
/**
 * Baked into the signature, not merely into the format. A token is a claim
 * about a scope; if the scope were only in the envelope, widening the format
 * later would silently promote every token already in a browser.
 */
const LEARN_TOKEN_SCOPE = `learn:${LEARN_CODE_TRACK}`;
export const LEARN_TOKEN_PREFIX = 'learn_';
/**
 * Long enough that someone working through onboarding is not interrupted,
 * short enough that a code rotation is not the only way to end a session. The
 * token grants nothing but the platform track, so the usual argument for a
 * short expiry — blast radius — barely applies.
 */
export const LEARN_TOKEN_TTL_MS = 12 * 60 * 60 * 1_000;

/**
 * The signing key, derived from the code rather than configured separately.
 *
 * This is what makes rotation total: change the code and every token already
 * in a browser stops verifying, with no revocation list to maintain and no
 * second secret to keep in step. The separator keeps the salt and the code
 * from running together, so no two codes can yield the same key material.
 */
function tokenKey(accessCode: string): Buffer {
  return createHash('sha256')
    .update(`pig.learn.token.${LEARN_TOKEN_VERSION}\u0000${accessCode}`)
    .digest();
}

export function mintLearnToken(accessCode: string, expiresAt: number): string {
  const signature = createHmac('sha256', tokenKey(accessCode))
    .update(`${LEARN_TOKEN_VERSION}.${LEARN_TOKEN_SCOPE}.${expiresAt}`)
    .digest('base64url');
  return `${LEARN_TOKEN_PREFIX}${LEARN_TOKEN_VERSION}.${expiresAt}.${signature}`;
}

export const LEARN_TOKEN_REJECTIONS = ['missing', 'malformed', 'expired', 'mismatch'] as const;
export type LearnTokenRejection = (typeof LEARN_TOKEN_REJECTIONS)[number];

export type LearnTokenResult =
  | { valid: true; expiresAt: number }
  | { valid: false; reason: LearnTokenRejection };

/**
 * Verify a learn token against the code currently in force.
 *
 * Follows the register of `integrations/hubspot/signature.ts`: recompute,
 * compare byte lengths first because `timingSafeEqual` throws on a mismatch,
 * then compare in constant time. Expiry is checked before the HMAC only
 * because an expired token is not a secret worth protecting the timing of.
 */
export function verifyLearnToken(
  accessCode: string | null,
  token: string | undefined,
  now: number = Date.now(),
): LearnTokenResult {
  if (!accessCode) return { valid: false, reason: 'mismatch' };
  if (!token) return { valid: false, reason: 'missing' };
  if (!token.startsWith(LEARN_TOKEN_PREFIX)) return { valid: false, reason: 'malformed' };

  const parts = token.slice(LEARN_TOKEN_PREFIX.length).split('.');
  if (parts.length !== 3) return { valid: false, reason: 'malformed' };
  const [version, rawExpiry, signature] = parts as [string, string, string];
  if (version !== LEARN_TOKEN_VERSION) return { valid: false, reason: 'malformed' };
  if (!/^\d{1,15}$/.test(rawExpiry)) return { valid: false, reason: 'malformed' };

  const expiresAt = Number(rawExpiry);
  if (!Number.isSafeInteger(expiresAt)) return { valid: false, reason: 'malformed' };
  if (expiresAt <= now) return { valid: false, reason: 'expired' };

  const expected = Buffer.from(
    createHmac('sha256', tokenKey(accessCode))
      .update(`${LEARN_TOKEN_VERSION}.${LEARN_TOKEN_SCOPE}.${expiresAt}`)
      .digest('base64url'),
    'utf8',
  );
  const supplied = Buffer.from(signature, 'utf8');
  if (expected.length !== supplied.length) return { valid: false, reason: 'mismatch' };
  return timingSafeEqual(expected, supplied)
    ? { valid: true, expiresAt }
    : { valid: false, reason: 'mismatch' };
}

// -------------------------------------------------------------- rate limiting

export interface AttemptDecision {
  allowed: boolean;
  remaining: number;
  retryAfterSeconds: number;
}

export interface AttemptLimiter {
  check(key: string, now?: number): AttemptDecision;
}

/**
 * A fixed-window limiter, in process.
 *
 * Deliberately not distributed and deliberately not durable. PIG runs as one
 * container; the job here is to stop a script walking a short passphrase
 * keyspace at HTTP speed, not to enforce an exact quota. A restart resetting
 * the window costs an attacker one restart's worth of guesses, which is not
 * the difference between safe and unsafe — the code length is.
 *
 * The map is pruned on write rather than on a timer, and capped, because the
 * key is a client-supplied-ish address and an unbounded map keyed on one is a
 * memory-exhaustion primitive.
 */
export function createAttemptLimiter({
  limit,
  windowMs,
  maxKeys = 10_000,
}: {
  limit: number;
  windowMs: number;
  maxKeys?: number;
}): AttemptLimiter {
  const windows = new Map<string, { count: number; resetAt: number }>();

  return {
    check(key, now = Date.now()) {
      if (windows.size >= maxKeys) {
        for (const [existing, window] of windows) {
          if (window.resetAt <= now) windows.delete(existing);
        }
        // Still full: every window is live, so this is either a real flood or
        // a spoofed-address one. Refuse rather than grow.
        if (windows.size >= maxKeys) {
          return { allowed: false, remaining: 0, retryAfterSeconds: Math.ceil(windowMs / 1000) };
        }
      }

      const current = windows.get(key);
      if (!current || current.resetAt <= now) {
        windows.set(key, { count: 1, resetAt: now + windowMs });
        return { allowed: true, remaining: limit - 1, retryAfterSeconds: 0 };
      }

      current.count += 1;
      if (current.count > limit) {
        return {
          allowed: false,
          remaining: 0,
          retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
        };
      }
      return { allowed: true, remaining: limit - current.count, retryAfterSeconds: 0 };
    },
  };
}

/**
 * Which client is this, for rate-limiting purposes?
 *
 * The LAST entry in `X-Forwarded-For`, not the first. Caddy APPENDS the real
 * peer to whatever the client sent, so the first hop is attacker-controlled
 * and using it hands anyone an unlimited number of rate-limit buckets. Behind
 * exactly one proxy — which is this deployment — the last entry is the only
 * one the client could not write.
 */
export function rateLimitKey(forwardedFor: string | undefined): string {
  if (!forwardedFor) return 'unknown';
  const hops = forwardedFor
    .split(',')
    .map((hop) => hop.trim())
    .filter(Boolean);
  return hops[hops.length - 1] ?? 'unknown';
}

// ------------------------------------------------------------------- schemas

const urlField = z.string().trim().min(1).max(2_000);

export const learnAccessSchema = z
  .object({ code: z.string().min(1).max(200) })
  .strict();

export const learnResourceCreateSchema = z
  .object({
    track: z.enum(LEARN_TRACKS),
    title: z.string().trim().min(1).max(200),
    summary: z.string().trim().max(1_000).optional(),
    url: urlField,
    visibility: z.enum(LEARN_VISIBILITIES).default('members'),
    // A day is generous for a walkthrough and rules out a mistyped
    // milliseconds value being stored as seconds.
    durationSeconds: z.number().int().positive().max(86_400).optional(),
    sortOrder: z.number().int().min(0).max(10_000).optional(),
    publishedAt: z.string().datetime().optional(),
  })
  .strict()
  .refine(
    (value) => learnVisibilityPermitted(value.track, value.visibility),
    'Only platform-track resources may be unlocked by the share code.',
  );

export const learnResourceUpdateSchema = z
  .object({
    track: z.enum(LEARN_TRACKS).optional(),
    title: z.string().trim().min(1).max(200).optional(),
    summary: z.string().trim().max(1_000).nullable().optional(),
    url: urlField.optional(),
    visibility: z.enum(LEARN_VISIBILITIES).optional(),
    durationSeconds: z.number().int().positive().max(86_400).nullable().optional(),
    sortOrder: z.number().int().min(0).max(10_000).optional(),
    publishedAt: z.string().datetime().optional(),
    archived: z.boolean().optional(),
  })
  .strict()
  .refine((value) => Object.values(value).some((item) => item !== undefined), 'No changes supplied.');

/**
 * A passphrase a human reads aloud, so printable ASCII and no whitespace.
 * Six is the floor because the endpoint is rate-limited, not because a short
 * code is otherwise fine.
 */
export const learnAccessCodeSchema = z
  .object({ code: z.string().trim().min(6).max(120).regex(/^[\x21-\x7e]+$/, 'Use printable characters with no spaces.') })
  .strict();

// ------------------------------------------------------------- serialisation

interface LearnRowForView {
  id: string;
  track: LearnTrack;
  title: string;
  summary: string | null;
  provider: LearnProvider;
  externalId: string;
  visibility: LearnVisibility;
  durationSeconds: number | null;
  sortOrder: number;
  publishedAt: Date;
}

/**
 * The wire shape. Note what is absent: the stored `url` column never leaves
 * the database. Both URLs a client receives are rebuilt from the allowlist,
 * so a row whose `url` was poisoned by some future write path still cannot put
 * an attacker's bytes into an `iframe src`.
 *
 * A row we cannot rebuild an embed for is dropped rather than returned
 * without one — it would render as a card that does nothing, and the honest
 * reading of "this provider is no longer enabled" is that the video is not
 * available, not that it is broken.
 */
export function learnResourceView(row: LearnRowForView) {
  const embed = learnEmbed(row.provider, row.externalId);
  const watchUrl = learnWatchUrl(row.provider, row.externalId);
  if (!embed || !watchUrl) return null;
  return {
    id: row.id,
    track: row.track,
    title: row.title,
    summary: row.summary,
    provider: row.provider,
    visibility: row.visibility,
    durationSeconds: row.durationSeconds,
    sortOrder: row.sortOrder,
    publishedAt: row.publishedAt,
    /**
     * The discriminated union — `kind: 'iframe' | 'video'` — is what the
     * client branches on. Sent alongside the flat `embedUrl` rather than
     * instead of it so a client mid-deploy keeps rendering; the flat field is
     * the same string and will go once nothing reads it.
     */
    embed,
    embedUrl: embed.src,
    watchUrl,
  };
}

export type LearnResourceView = NonNullable<ReturnType<typeof learnResourceView>>;

function renderable(rows: LearnRowForView[]): LearnResourceView[] {
  return rows.map(learnResourceView).filter((view): view is LearnResourceView => view !== null);
}

/** Enumerated rather than `select()`, so `url` cannot be added by accident. */
const viewColumns = {
  id: learnResources.id,
  track: learnResources.track,
  title: learnResources.title,
  summary: learnResources.summary,
  provider: learnResources.provider,
  externalId: learnResources.externalId,
  visibility: learnResources.visibility,
  durationSeconds: learnResources.durationSeconds,
  sortOrder: learnResources.sortOrder,
  publishedAt: learnResources.publishedAt,
} as const;

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

async function currentAccessCode(db: Database): Promise<string | null> {
  const [row] = await db
    .select({ code: platformSettings.learnAccessCode })
    .from(platformSettings)
    .where(eq(platformSettings.id, SETTINGS_ID))
    .limit(1);
  // No settings row means the workspace has not been initialised. Refusing
  // every code is the correct answer; inserting a row here would write default
  // Piggy configuration over what `ensurePlatformSettings` derives from the
  // environment, which is a far worse bug than an unusable share code.
  return row?.code ?? null;
}

export function createLearnRoutes(
  db: Database,
  options: { limiter?: AttemptLimiter } = {},
) {
  const app = new Hono<ApiEnv>();
  // Ten guesses a minute per address. A human who has been given the code
  // types it once; anything approaching this rate is a script.
  const limiter = options.limiter ?? createAttemptLimiter({ limit: 10, windowMs: 60_000 });

  // ------------------------------------------------------------- public door

  app.post(LEARN_ACCESS_PATH, async (c) => {
    const decision = limiter.check(rateLimitKey(c.req.header('x-forwarded-for')));
    if (!decision.allowed) {
      c.header('retry-after', String(decision.retryAfterSeconds));
      return c.json(
        apiError('learn_rate_limited', 'Too many attempts. Try again shortly.'),
        429,
      );
    }

    let body: unknown;
    try {
      body = await c.req.json();
    } catch {
      return c.json(apiError('invalid_json', 'Request body must be valid JSON.'), 400);
    }
    const parsed = learnAccessSchema.safeParse(body);
    if (!parsed.success) {
      return c.json(apiError('invalid_request', 'Supply an access code.', parsed.error.issues), 400);
    }

    const accessCode = await currentAccessCode(db);
    if (!accessCode || !safeEqual(parsed.data.code.trim(), accessCode)) {
      // One message for "wrong code" and "no code configured". Distinguishing
      // them tells a guesser whether to keep going.
      return c.json(apiError('invalid_code', 'That code is not valid.'), 401);
    }

    const expiresAt = Date.now() + LEARN_TOKEN_TTL_MS;
    return c.json({
      token: mintLearnToken(accessCode, expiresAt),
      expiresAt: new Date(expiresAt).toISOString(),
      /**
       * Stated in the response because the front end has to be able to explain
       * to a code-holder why the Concepts section is locked, and hardcoding
       * that in the browser would be a second place to change it.
       */
      track: LEARN_CODE_TRACK,
    });
  });

  /**
   * The only read a non-member can perform.
   *
   * Two literal predicates and no parameters. There is nothing in this handler
   * that a caller can influence except the token, which decides whether it
   * runs at all — not what it returns.
   */
  app.get(LEARN_PUBLIC_PATH, async (c) => {
    const header = c.req.header('authorization');
    const supplied = header?.startsWith('Bearer ') ? header.slice(7).trim() : undefined;
    const result = verifyLearnToken(await currentAccessCode(db), supplied);
    if (!result.valid) {
      return c.json(
        apiError(
          result.reason === 'expired' ? 'learn_token_expired' : 'learn_token_invalid',
          result.reason === 'expired'
            ? 'That access has expired. Enter the code again.'
            : 'A valid access code is required.',
        ),
        401,
      );
    }

    const rows = await db
      .select(viewColumns)
      .from(learnResources)
      .where(
        and(
          eq(learnResources.visibility, 'code'),
          eq(learnResources.track, LEARN_CODE_TRACK),
          isNull(learnResources.archivedAt),
        ),
      )
      .orderBy(asc(learnResources.sortOrder), desc(learnResources.publishedAt));

    return c.json({
      track: LEARN_CODE_TRACK,
      expiresAt: new Date(result.expiresAt).toISOString(),
      resources: renderable(rows),
      /** So the locked Concepts panel can name what is behind it. */
      lockedTracks: LEARN_TRACKS.filter((track) => track !== LEARN_CODE_TRACK),
    });
  });

  // ------------------------------------------------------------ member reads

  /**
   * The whole curriculum. Any member may read it: this is training material,
   * and gating supply concepts behind supply-team membership would stop a new
   * demand seller learning how the other side works, which is the opposite of
   * what the page is for.
   */
  app.get('/api/learn', async (c) => {
    const rows = await db
      .select(viewColumns)
      .from(learnResources)
      .where(isNull(learnResources.archivedAt))
      .orderBy(asc(learnResources.sortOrder), desc(learnResources.publishedAt));

    const views = renderable(rows);
    return c.json({
      tracks: Object.fromEntries(
        LEARN_TRACKS.map((track) => [track, views.filter((view) => view.track === track)]),
      ) as Record<LearnTrack, LearnResourceView[]>,
      canManage: canManageLearn(c.get('principal')),
    });
  });

  // ------------------------------------------------------------- admin write

  app.post(
    '/api/learn/resources',
    mutation(db, {
      schema: learnResourceCreateSchema,
      permission: { capability: 'settings:admin' },
      invalidMessage: 'Invalid learn resource.',
      async mutate({ input, principal, tx, now }) {
        const resolved = resolveEmbedOrThrow(input.url);
        const [created] = await tx
          .insert(learnResources)
          .values({
            track: input.track,
            title: input.title,
            summary: input.summary ?? null,
            // The canonical form from the allowlist, not the pasted string —
            // so the stored value is one we generated even in the column
            // nothing renders.
            url: resolved.watchUrl,
            provider: resolved.provider,
            externalId: resolved.externalId,
            visibility: input.visibility,
            durationSeconds: input.durationSeconds ?? null,
            sortOrder: input.sortOrder ?? 100,
            publishedAt: input.publishedAt ? new Date(input.publishedAt) : now,
            addedByUserId: principal.userId,
            createdAt: now,
            updatedAt: now,
          })
          .returning();
        if (!created) throw new Error('Learn resource insert returned no row');

        return {
          data: viewOrThrow(created),
          activity: {
            type: 'agent_action',
            subject: `Added learn resource: ${created.title}`,
            meta: {
              action: 'learn_resource.created',
              resourceId: created.id,
              track: created.track,
              visibility: created.visibility,
              provider: created.provider,
            },
          },
        };
      },
    }),
  );

  app.patch(
    '/api/learn/resources/:id',
    mutation(db, {
      schema: learnResourceUpdateSchema,
      permission: { capability: 'settings:admin' },
      invalidMessage: 'Invalid learn resource change.',
      async mutate({ input, params, tx, now }) {
        const id = requiredId(params);
        const [existing] = await tx
          .select()
          .from(learnResources)
          .where(eq(learnResources.id, id))
          .limit(1);
        if (!existing) throw MutationError.notFound('Learn resource');

        /*
         * Checked against the MERGED row, not the input. A PATCH that sets
         * only `visibility: 'code'` on a supply resource carries no track at
         * all, so validating the input alone would wave it straight through
         * into the CHECK constraint and a 500.
         */
        const track = input.track ?? existing.track;
        const visibility = input.visibility ?? existing.visibility;
        if (!learnVisibilityPermitted(track, visibility)) {
          throw new MutationError(
            'visibility_not_permitted',
            `Only ${LEARN_CODE_TRACK}-track resources may be unlocked by the share code.`,
            409,
          );
        }

        const set: Partial<typeof learnResources.$inferInsert> = { updatedAt: now };
        if (input.track !== undefined) set.track = input.track;
        if (input.title !== undefined) set.title = input.title;
        if (input.summary !== undefined) set.summary = input.summary;
        if (input.visibility !== undefined) set.visibility = input.visibility;
        if (input.durationSeconds !== undefined) set.durationSeconds = input.durationSeconds;
        if (input.sortOrder !== undefined) set.sortOrder = input.sortOrder;
        if (input.publishedAt !== undefined) set.publishedAt = new Date(input.publishedAt);
        if (input.archived !== undefined) set.archivedAt = input.archived ? now : null;
        if (input.url !== undefined) {
          const resolved = resolveEmbedOrThrow(input.url);
          set.url = resolved.watchUrl;
          set.provider = resolved.provider;
          set.externalId = resolved.externalId;
        }

        const [updated] = await tx
          .update(learnResources)
          .set(set)
          .where(eq(learnResources.id, id))
          .returning();
        if (!updated) throw MutationError.notFound('Learn resource');

        return {
          data: viewOrThrow(updated),
          activity: {
            type: 'agent_action',
            subject: `Updated learn resource: ${updated.title}`,
            meta: {
              action: 'learn_resource.updated',
              resourceId: updated.id,
              fields: Object.keys(input),
              track: updated.track,
              visibility: updated.visibility,
            },
          },
        };
      },
    }),
  );

  app.delete(
    '/api/learn/resources/:id',
    bodylessMutation(db, {
      schema: z.object({}).strict(),
      permission: { capability: 'settings:admin' },
      invalidMessage: 'Invalid learn resource removal.',
      // Archive, never delete: the activity log references the row, and "what
      // did onboarding say in March?" is a real question.
      async mutate({ params, tx, now }) {
        const id = requiredId(params);
        const [existing] = await tx
          .select()
          .from(learnResources)
          .where(eq(learnResources.id, id))
          .limit(1);
        if (!existing) throw MutationError.notFound('Learn resource');

        const [archived] = existing.archivedAt
          ? [existing]
          : await tx
              .update(learnResources)
              .set({ archivedAt: now, updatedAt: now })
              .where(eq(learnResources.id, id))
              .returning();
        if (!archived) throw MutationError.notFound('Learn resource');

        return {
          data: { id: archived.id, archivedAt: archived.archivedAt },
          activity: {
            type: 'agent_action',
            subject: `Archived learn resource: ${archived.title}`,
            meta: {
              action: 'learn_resource.archived',
              resourceId: archived.id,
              alreadyArchived: existing.archivedAt !== null,
            },
          },
        };
      },
    }),
  );

  // ------------------------------------------------------------ the code itself

  /**
   * Returned in clear to a platform administrator, deliberately.
   *
   * It is a passphrase they have to be able to read out to the person they are
   * sharing a demo with — a code an admin cannot see is a code nobody can use.
   * It is not a credential for anything but the platform track, and the read
   * requires `settings:admin`.
   */
  app.get('/api/learn/access-code', async (c) => {
    requireCapability(c.get('principal'), 'settings:admin');
    const [row] = await db
      .select({
        code: platformSettings.learnAccessCode,
        updatedAt: platformSettings.learnAccessCodeUpdatedAt,
      })
      .from(platformSettings)
      .where(eq(platformSettings.id, SETTINGS_ID))
      .limit(1);
    if (!row) {
      return c.json(
        apiError('settings_uninitialised', 'Open Settings once to initialise this workspace.'),
        409,
      );
    }
    return c.json({ code: row.code, updatedAt: row.updatedAt, url: '/learn' });
  });

  app.patch(
    '/api/learn/access-code',
    mutation(db, {
      schema: learnAccessCodeSchema,
      permission: { capability: 'settings:admin' },
      invalidMessage: 'Invalid access code.',
      async mutate({ input, tx, now }) {
        /*
         * UPDATE, never upsert. Inserting the row here would give it default
         * Piggy configuration rather than the environment-derived values
         * `ensurePlatformSettings` writes, silently disabling Piggy — a much
         * worse outcome than telling an administrator to open Settings first.
         */
        const [updated] = await tx
          .update(platformSettings)
          .set({ learnAccessCode: input.code, learnAccessCodeUpdatedAt: now, updatedAt: now })
          .where(eq(platformSettings.id, SETTINGS_ID))
          .returning({
            code: platformSettings.learnAccessCode,
            updatedAt: platformSettings.learnAccessCodeUpdatedAt,
          });
        if (!updated) {
          throw new MutationError(
            'settings_uninitialised',
            'Open Settings once to initialise this workspace.',
            409,
          );
        }

        return {
          data: updated,
          activity: {
            type: 'agent_action',
            // The code itself is never written to the activity log: that log
            // is readable by every member, and rotating a code into it would
            // defeat the rotation.
            subject: 'Rotated the Learn share code',
            meta: { action: 'learn_access_code.rotated' },
          },
        };
      },
    }),
  );

  return app;
}

// ------------------------------------------------------------------- helpers

/** Curating the curriculum is an administrative act, not a GTM one. */
export function canManageLearn(principal: { isPlatformAdmin: boolean; scopes: string[] }): boolean {
  return principal.isPlatformAdmin && principal.scopes.includes('write');
}

/**
 * A row that has just passed `resolveEmbedOrThrow` must be renderable, so a
 * null here is a contradiction between the resolver and the rebuilder rather
 * than a resource that is merely unavailable. Fail loudly.
 */
function viewOrThrow(row: LearnRowForView): LearnResourceView {
  const view = learnResourceView(row);
  if (!view) throw new Error(`Learn resource ${row.id} was written but cannot be rendered`);
  return view;
}

function requiredId(params: Readonly<Record<string, string>>): string {
  const id = params.id;
  if (!id) throw new MutationError('invalid_route_parameter', "Route parameter 'id' is required.", 400);
  return id;
}

/**
 * The write-path half of the allowlist. An unmatched URL is a 400, not a row —
 * which is what makes "no unresolvable resource exists" an invariant rather
 * than a hope.
 */
function resolveEmbedOrThrow(url: string) {
  const resolved = resolveLearnEmbed(url);
  if (!resolved.ok) {
    throw new MutationError(
      'invalid_video_url',
      LEARN_EMBED_REJECTION_MESSAGES[resolved.reason],
      400,
    );
  }
  return resolved;
}
