/**
 * Motion — the HTTP surface for the library, the engagements and the loop.
 *
 * Thin by design. Every decision worth arguing about — who may see a private
 * template, what promotion writes, how a score is computed — is in
 * `services/motion.ts`, so Piggy reaches the same answers in-process without
 * going through a handler. The writes are exported `MutationDefinition`
 * factories in the register of `capacity-writes.ts`, which is what lets a test
 * drive the rules in §7 without an HTTP server or a database.
 *
 * Two things here are not obvious from the endpoint table.
 *
 * **`motion:write` and `motion:publish` are team capabilities held on any
 * team**, so they are enforced with `requireAnyTeamCapability` rather than a
 * `{ capability, team }` pair. A motion template belongs to a person and a
 * lineage, not to supply or demand; picking a team to check against would mean
 * inventing one, and inventing one is how a research lead ends up unable to
 * publish the reference architecture they are the one writing.
 *
 * **Creating or forking straight to `shared` needs `motion:publish`.** The
 * publish endpoint is not the only way into the shared library — `POST
 * /templates` and `POST /templates/:id/versions` both take a `visibility` — so
 * the gate is applied in all three places rather than on the one door that
 * happens to be named after it.
 */
import {
  ARTIFACT_STATUSES,
  DEMAND_STAGES,
  ENGAGEMENT_STATUSES,
  MOTION_KINDS,
  MOTION_MAX_DIMENSION_SCORE,
  MOTION_MIN_DIMENSION_SCORE,
  MOTION_VISIBILITIES,
  motionBand,
  permissionGranted,
  resolveWritePermissionGrants,
} from '@pig/core';
import type {
  Database,
  Engagement,
  EngagementArtifact,
  MotionTemplate,
  QualificationScore,
} from '@pig/db';
import { demandDeals, engagementArtifacts, engagements, motionTemplates, users } from '@pig/db';
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { requireAnyTeamCapability, type Principal } from '../lib/auth';
import {
  apiError,
  bodylessMutation,
  MutationError,
  mutation,
  type ApiEnv,
  type MutationDefinition,
} from '../lib/mutation';
import {
  assertTemplateEditable,
  assertTemplateWritable,
  instantiateArtifact,
  loadEngagement,
  loadTemplateForWrite,
  lockNewestVersion,
  lockTemplateForWrite,
  motionSlug,
  MotionService,
  newestVisibleInLineage,
  ownsTemplate,
  promoteArtifact,
  recordScore,
  versionConflict,
  type MotionTransaction,
  type MotionViewer,
} from '../services/motion';

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

const uuid = z.string().uuid();
const title = z.string().trim().min(1).max(200);
const summary = z.string().trim().min(1).max(2_000);
/** Markdown, and long: a playbook is a document, not a field. */
const bodyText = z.string().max(200_000);
const slug = z.string().trim().min(1).max(80);
const fields = z.record(z.unknown()).nullable();

const templateCreateSchema = z
  .object({
    kind: z.enum(MOTION_KINDS),
    slug: slug.optional(),
    title,
    summary,
    body: bodyText,
    stage: z.enum(DEMAND_STAGES),
    fields: fields.optional(),
    visibility: z.enum(MOTION_VISIBILITIES).optional(),
  })
  .strict();

/**
 * No `kind` and no `visibility`. A lineage that changes kind halfway is a
 * different template wearing the same slug, and visibility moves through
 * `/publish`, which is where the capability check lives.
 */
const templateUpdateSchema = z
  .object({ title, summary, body: bodyText, stage: z.enum(DEMAND_STAGES), fields })
  .partial()
  .strict()
  .refine((input) => Object.keys(input).length > 0, 'At least one change is required.');

const templateVersionSchema = z
  .object({
    title: title.optional(),
    summary: summary.optional(),
    body: bodyText.optional(),
    stage: z.enum(DEMAND_STAGES).optional(),
    fields: fields.optional(),
    visibility: z.enum(MOTION_VISIBILITIES).optional(),
  })
  .strict();

const emptySchema = z.object({}).strict();

const engagementCreateSchema = z
  .object({
    demandDealId: uuid,
    playbookTemplateId: uuid.nullable().optional(),
    ownerUserId: uuid.nullable().optional(),
    summary: z.string().trim().max(2_000).nullable().optional(),
  })
  .strict();

const engagementUpdateSchema = z
  .object({
    status: z.enum(ENGAGEMENT_STATUSES),
    ownerUserId: uuid.nullable(),
    playbookTemplateId: uuid.nullable(),
    summary: z.string().trim().max(2_000).nullable(),
  })
  .partial()
  .strict()
  .refine((input) => Object.keys(input).length > 0, 'At least one change is required.');

const artifactCreateSchema = z
  .object({
    templateId: uuid.optional(),
    kind: z.enum(MOTION_KINDS).optional(),
    stage: z.enum(DEMAND_STAGES).optional(),
    title: title.optional(),
    body: bodyText.optional(),
    fields: fields.optional(),
    status: z.enum(ARTIFACT_STATUSES).optional(),
  })
  .strict()
  .refine(
    (input) =>
      Boolean(input.templateId) ||
      Boolean(input.kind && input.stage && input.title && input.body !== undefined),
    'Supply a templateId to instantiate, or a kind, stage, title and body.',
  );

const artifactUpdateSchema = z
  .object({
    title,
    body: bodyText,
    fields,
    stage: z.enum(DEMAND_STAGES),
    status: z.enum(ARTIFACT_STATUSES),
    archived: z.boolean(),
  })
  .partial()
  .strict()
  .refine((input) => Object.keys(input).length > 0, 'At least one change is required.');

const promoteSchema = z
  .object({ slug: slug.optional(), title: title.optional(), summary: summary.optional() })
  .strict();

/**
 * Weights and scores are integers, and the total is recomputed server-side from
 * these — the client never posts a score. Bounds come from `@pig/core` so the
 * scale is defined in one place.
 */
const scoreSchema = z
  .object({
    dimensions: z
      .array(
        z
          .object({
            id: z.string().trim().min(1).max(120),
            weight: z.number().int().min(0).max(1_000),
            score: z
              .number()
              .int()
              .min(MOTION_MIN_DIMENSION_SCORE)
              .max(MOTION_MAX_DIMENSION_SCORE),
          })
          .strict(),
      )
      .max(50),
    frameworkTemplateId: uuid.nullable().optional(),
    note: z.string().trim().max(2_000).nullable().optional(),
  })
  .strict();

const filtersSchema = z
  .object({
    kind: z.enum(MOTION_KINDS).optional(),
    stage: z.enum(DEMAND_STAGES).optional(),
    visibility: z.enum(MOTION_VISIBILITIES).optional(),
    q: z.string().trim().max(200).optional(),
    all: z.string().optional(),
  })
  .strict();

const engagementFiltersSchema = z
  .object({ status: z.enum(ENGAGEMENT_STATUSES).optional() })
  .strict();

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

function viewerOf(principal: Principal): MotionViewer {
  return { userId: principal.userId, isPlatformAdmin: principal.isPlatformAdmin };
}

const authorizeWrite = {
  authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:write'),
};
const authorizePublish = {
  authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:publish'),
};

/**
 * A template id somebody sent, checked before it is stored.
 *
 * `playbook_template_id` and `framework_template_id` are provenance, and they
 * are returned book-wide on every engagement summary. Written raw, an id
 * nobody but its owner may read becomes public through the engagement that
 * cites it, and a wrong id is a foreign-key 500 rather than a 404. Reading it
 * through the visibility filter makes both cases a clean not-found.
 */
async function checkedTemplateId(
  tx: MotionTransaction,
  principal: Principal,
  id: string | null | undefined,
): Promise<void> {
  if (!id) return;
  await loadTemplateForWrite(tx, viewerOf(principal), id);
}

/**
 * An owner id somebody sent, checked before it is stored.
 *
 * `owner_user_id` is a foreign key with nothing in front of it, so an id for a
 * user who has since been removed leaves as a 500 from the constraint rather
 * than the 404 every other reference here answers with. Only ever the id the
 * client actually supplied: an engagement created without one defaults to the
 * caller, and re-reading a user the request has just authenticated would be a
 * query bought with nothing — the same decision `calendar.ts` makes, and one a
 * test there pins. It also leaves an explicit `ownerUserId: null` — unassigning
 * — as the no-op it is.
 */
async function checkedOwnerUserId(
  tx: MotionTransaction,
  id: string | null | undefined,
): Promise<void> {
  if (!id) return;
  const [owner] = await tx.select({ id: users.id }).from(users).where(eq(users.id, id)).limit(1);
  if (!owner) throw MutationError.notFound('User');
}

/** The same gate as `authorizePublish`, applied once the body says `shared`. */
function requirePublishFor(principal: Principal, visibility: string | undefined): void {
  if (visibility === 'shared') requireAnyTeamCapability(principal, 'motion:publish');
}

/**
 * The hint the detail page renders its Publish button from. It checks the
 * `write` scope as well as the capability, because `requireAnyTeamCapability`
 * does — a read-only API key belonging to a lead would otherwise be told it may
 * publish and then be refused `insufficient_scope` by the endpoint itself.
 */
function canPublish(principal: Principal): boolean {
  return (
    principal.scopes.includes('write') &&
    permissionGranted(resolveWritePermissionGrants(principal), 'motion:publish')
  );
}

/**
 * The id in the path, or a 404 — including when it is not a uuid at all.
 *
 * The shape check is the load-bearing half. Every id column here is `uuid`, so
 * an id like `nope` reaches Postgres as a parameter it cannot cast and comes
 * back as `22P02 invalid input syntax for type uuid`, which is not a
 * `MutationError` and so leaves as a 500 with `{"error":"Internal error"}`.
 * Measured against a running server before this was written: five of the eleven
 * `:id` routes answered 500, and the other six only answered 400 because their
 * body schema happened to be checked first — a valid body would have reached
 * the same cast.
 *
 * 404 rather than 400, deliberately, and for the same reason the template read
 * answers 404 for somebody else's private draft: an id that cannot name a row
 * is an id for a row that does not exist, and two different codes for "no such
 * template" would tell an enumerating caller which ids are well-formed.
 */
function requiredId(params: Readonly<Record<string, string>>, resource: string): string {
  const id = params.id;
  if (!id || !uuid.safeParse(id).success) throw MutationError.notFound(resource);
  return id;
}

// ------------------------------------------------------------ library writes

export function motionTemplateCreateDefinition(): MutationDefinition<
  typeof templateCreateSchema,
  { template: MotionTemplate }
> {
  return {
    schema: templateCreateSchema,
    permission: authorizeWrite,
    invalidMessage: 'Invalid motion template.',
    async mutate({ input, principal, tx, now }) {
      requirePublishFor(principal, input.visibility);
      const slug = motionSlug(input.slug ?? input.title);

      // Checked rather than left to `(slug, version)`, for the reason the
      // engagement create gives below: a title that derives an existing slug —
      // "Proposal Blocks" is one of the nine shipped starters — would otherwise
      // be a 500 with `Internal error` and no way for the caller to tell what
      // they collided with.
      if (await lockNewestVersion(tx, slug)) {
        throw new MutationError(
          'template_slug_exists',
          `A template lineage already uses the slug "${slug}". POST /api/motion/templates/<id>/versions to add a version to it, or supply a different slug.`,
          409,
        );
      }

      const [created] = await tx
        .insert(motionTemplates)
        .values({
          kind: input.kind,
          slug,
          version: 1,
          title: input.title,
          summary: input.summary,
          body: input.body,
          fields: input.fields ?? null,
          stage: input.stage,
          visibility: input.visibility ?? 'private',
          // Never null. The CHECK refuses an unowned private row, and an
          // unowned private row is one nobody could ever read back.
          ownerUserId: principal.userId,
          createdAt: now,
          updatedAt: now,
        })
        .returning()
        .catch(versionConflict);
      if (!created) throw new Error('Motion template insert returned no row');

      return {
        data: { template: created },
        activity: {
          type: 'note',
          subject: `Added a motion template: ${created.title}`,
          meta: {
            action: 'motion_template.created',
            templateId: created.id,
            kind: created.kind,
            slug: created.slug,
            visibility: created.visibility,
          },
        },
      };
    },
  };
}

export function motionTemplateUpdateDefinition(): MutationDefinition<
  typeof templateUpdateSchema,
  { template: MotionTemplate }
> {
  return {
    schema: templateUpdateSchema,
    permission: authorizeWrite,
    invalidMessage: 'Invalid motion template change.',
    async mutate({ input, params, principal, tx, now }) {
      const viewer = viewerOf(principal);
      // Locked, so the `usage_count` that §7a is about to be judged on is the
      // one belonging to the row this transaction goes on to write. Read
      // without the lock it is a count that a concurrent instantiation can
      // move between the check and the UPDATE.
      const existing = await lockTemplateForWrite(
        tx,
        viewer,
        requiredId(params, 'Motion template'),
      );
      assertTemplateWritable(viewer, existing);
      assertTemplateEditable(existing);

      const [updated] = await tx
        .update(motionTemplates)
        .set({
          ...(input.title !== undefined ? { title: input.title } : {}),
          ...(input.summary !== undefined ? { summary: input.summary } : {}),
          ...(input.body !== undefined ? { body: input.body } : {}),
          ...(input.stage !== undefined ? { stage: input.stage } : {}),
          ...(input.fields !== undefined ? { fields: input.fields } : {}),
          updatedAt: now,
        })
        .where(eq(motionTemplates.id, existing.id))
        .returning();
      if (!updated) throw MutationError.notFound('Motion template');

      return {
        data: { template: updated },
        activity: {
          type: 'note',
          subject: `Edited a motion template: ${updated.title}`,
          meta: {
            action: 'motion_template.updated',
            templateId: updated.id,
            fields: Object.keys(input),
          },
        },
      };
    },
  };
}

/**
 * A new version of a lineage — the answer to a `409 template_in_use`, and the
 * "fork to private" affordance, in one endpoint.
 *
 * Ownership is deliberately NOT required: a shared template belongs to the
 * book, and forking it into your own private draft is what a library is for.
 * What is required is `motion:publish` if the fork is to land shared.
 */
export function motionTemplateVersionDefinition(): MutationDefinition<
  typeof templateVersionSchema,
  { template: MotionTemplate }
> {
  return {
    schema: templateVersionSchema,
    permission: authorizeWrite,
    invalidMessage: 'Invalid motion template version.',
    async mutate({ input, params, principal, tx, now }) {
      requirePublishFor(principal, input.visibility);
      const viewer = viewerOf(principal);
      const source = await loadTemplateForWrite(
        tx,
        viewer,
        requiredId(params, 'Motion template'),
      );
      // Two different questions, deliberately answered by two queries. The
      // version has to be allocated against the whole lineage or it collides
      // with a private fork nobody else can see, but `supersedes_id` must name
      // a row this caller could actually fetch — otherwise a fork of a shared
      // v1 comes back claiming to supersede somebody's private v4, which both
      // leaks that the private versions exist and records a lineage edge that
      // is not the one the user made.
      const newest = await lockNewestVersion(tx, source.slug);
      const previous = (await newestVisibleInLineage(tx, viewer, source.slug)) ?? source;

      const [created] = await tx
        .insert(motionTemplates)
        .values({
          kind: source.kind,
          slug: source.slug,
          version: (newest?.version ?? source.version) + 1,
          title: input.title ?? source.title,
          summary: input.summary ?? source.summary,
          body: input.body ?? source.body,
          fields: input.fields !== undefined ? input.fields : source.fields,
          stage: input.stage ?? source.stage,
          // Private unless asked otherwise, so forking a shared template is
          // never accidentally a publication.
          visibility: input.visibility ?? 'private',
          ownerUserId: principal.userId,
          supersedesId: previous.id,
          isSystem: false,
          createdAt: now,
          updatedAt: now,
        })
        .returning()
        .catch(versionConflict);
      if (!created) throw new Error('Motion template version insert returned no row');

      return {
        data: { template: created },
        activity: {
          type: 'note',
          subject: `New version of ${created.title} (v${created.version})`,
          meta: {
            action: 'motion_template.versioned',
            templateId: created.id,
            supersedesId: previous.id,
            slug: created.slug,
            version: created.version,
          },
        },
      };
    },
  };
}

export function motionTemplatePublishDefinition(): MutationDefinition<
  typeof emptySchema,
  { template: MotionTemplate }
> {
  return {
    schema: emptySchema,
    permission: authorizePublish,
    invalidMessage: 'Invalid publish request.',
    async mutate({ params, principal, tx, now }) {
      const viewer = viewerOf(principal);
      const existing = await loadTemplateForWrite(
        tx,
        viewer,
        requiredId(params, 'Motion template'),
      );
      assertTemplateWritable(viewer, existing);

      // Idempotent: publishing twice is the same statement made twice, and a
      // 409 here would only ever fire on a double-clicked button.
      const [published] =
        existing.visibility === 'shared'
          ? [existing]
          : await tx
              .update(motionTemplates)
              .set({ visibility: 'shared', updatedAt: now })
              .where(eq(motionTemplates.id, existing.id))
              .returning();
      if (!published) throw MutationError.notFound('Motion template');

      return {
        data: { template: published },
        activity: {
          type: 'note',
          subject: `Published to the library: ${published.title} (v${published.version})`,
          meta: {
            action: 'motion_template.published',
            templateId: published.id,
            alreadyShared: existing.visibility === 'shared',
          },
        },
      };
    },
  };
}

/**
 * Archive, never delete: an engagement artifact records the template it came
 * from, and provenance that can vanish is not provenance.
 */
export function motionTemplateArchiveDefinition(): MutationDefinition<
  typeof emptySchema,
  { id: string; archivedAt: Date | null }
> {
  return {
    schema: emptySchema,
    permission: authorizeWrite,
    invalidMessage: 'Invalid template removal.',
    async mutate({ params, principal, tx, now }) {
      const viewer = viewerOf(principal);
      const existing = await loadTemplateForWrite(
        tx,
        viewer,
        requiredId(params, 'Motion template'),
      );
      assertTemplateWritable(viewer, existing);

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

      return {
        data: { id: archived.id, archivedAt: archived.archivedAt },
        activity: {
          type: 'note',
          subject: `Archived a motion template: ${archived.title}`,
          meta: {
            action: 'motion_template.archived',
            templateId: archived.id,
            alreadyArchived: existing.archivedAt !== null,
          },
        },
      };
    },
  };
}

// --------------------------------------------------------- engagement writes

export function motionEngagementCreateDefinition(): MutationDefinition<
  typeof engagementCreateSchema,
  { engagement: Engagement }
> {
  return {
    schema: engagementCreateSchema,
    permission: authorizeWrite,
    invalidMessage: 'Invalid engagement.',
    async mutate({ input, principal, tx, now }) {
      // Locked, because the existence check below is only worth making if it
      // cannot be raced. Two simultaneous opens of one deal both saw nothing,
      // both inserted, and the loser got `23505` on
      // `engagements_demand_deal_key` — not a `MutationError`, so a 500 with
      // no way to tell that an engagement now exists. Concurrent opens queue
      // on this row instead, and the loser's check sees the committed
      // engagement and answers with its id, as the comment below promises.
      const [deal] = await tx
        .select()
        .from(demandDeals)
        .where(eq(demandDeals.id, input.demandDealId))
        .limit(1)
        .for('update');
      if (!deal) throw MutationError.notFound('Demand deal');
      await checkedTemplateId(tx, principal, input.playbookTemplateId);
      await checkedOwnerUserId(tx, input.ownerUserId);

      // Checked rather than left to the unique constraint, so the caller gets
      // the id of the engagement that already exists instead of a 500.
      const [existing] = await tx
        .select()
        .from(engagements)
        .where(eq(engagements.demandDealId, deal.id))
        .limit(1);
      if (existing) {
        throw new MutationError(
          'engagement_exists',
          `This deal already has an engagement (${existing.id}).`,
          409,
        );
      }

      const [created] = await tx
        .insert(engagements)
        .values({
          demandDealId: deal.id,
          playbookTemplateId: input.playbookTemplateId ?? null,
          ownerUserId: input.ownerUserId ?? principal.userId,
          summary: input.summary ?? null,
          openedAt: now,
          createdAt: now,
          updatedAt: now,
        })
        .returning();
      if (!created) throw new Error('Engagement insert returned no row');

      return {
        data: { engagement: created },
        activity: {
          type: 'note',
          subject: `Opened an engagement on ${deal.name}`,
          accountId: deal.accountId,
          demandDealId: deal.id,
          meta: { action: 'engagement.opened', engagementId: created.id },
        },
      };
    },
  };
}

export function motionEngagementUpdateDefinition(): MutationDefinition<
  typeof engagementUpdateSchema,
  { engagement: Engagement }
> {
  return {
    schema: engagementUpdateSchema,
    permission: authorizeWrite,
    invalidMessage: 'Invalid engagement change.',
    async mutate({ input, params, principal, tx, now }) {
      const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
      await checkedTemplateId(tx, principal, input.playbookTemplateId);
      await checkedOwnerUserId(tx, input.ownerUserId);

      const [updated] = await tx
        .update(engagements)
        .set({
          ...(input.status !== undefined ? { status: input.status } : {}),
          ...(input.ownerUserId !== undefined ? { ownerUserId: input.ownerUserId } : {}),
          ...(input.playbookTemplateId !== undefined
            ? { playbookTemplateId: input.playbookTemplateId }
            : {}),
          ...(input.summary !== undefined ? { summary: input.summary } : {}),
          // `closed_at` follows the status rather than being sent, so a
          // reopened engagement cannot keep a close date it no longer has.
          ...(input.status !== undefined
            ? { closedAt: input.status === 'won' || input.status === 'lost' ? now : null }
            : {}),
          updatedAt: now,
        })
        .where(eq(engagements.id, engagement.id))
        .returning();
      if (!updated) throw MutationError.notFound('Engagement');

      return {
        data: { engagement: updated },
        activity: {
          type: 'note',
          subject: `Updated the engagement on ${deal.name}`,
          accountId: deal.accountId,
          demandDealId: deal.id,
          meta: {
            action: 'engagement.updated',
            engagementId: updated.id,
            fields: Object.keys(input),
            status: updated.status,
          },
        },
      };
    },
  };
}

export function motionArtifactCreateDefinition(): MutationDefinition<
  typeof artifactCreateSchema,
  { artifact: EngagementArtifact }
> {
  return {
    schema: artifactCreateSchema,
    permission: authorizeWrite,
    invalidMessage: 'Invalid engagement artefact.',
    async mutate({ input, params, principal, tx, now }) {
      const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
      const { artifact, template } = await instantiateArtifact(
        tx,
        viewerOf(principal),
        { ...input, engagementId: engagement.id },
        now,
      );

      return {
        data: { artifact },
        activity: {
          type: 'note',
          subject: template
            ? `Instantiated ${template.title} (v${template.version}) on ${deal.name}`
            : `Added a ${artifact.kind} artefact to ${deal.name}`,
          accountId: deal.accountId,
          demandDealId: deal.id,
          meta: {
            action: 'engagement_artifact.created',
            engagementId: engagement.id,
            artifactId: artifact.id,
            templateId: template?.id ?? null,
            kind: artifact.kind,
            stage: artifact.stage,
          },
        },
      };
    },
  };
}

/**
 * Not restricted to the author: an engagement is worked by a team, and an
 * artifact nobody but its author can finish is one that stalls the week they go
 * on leave. The library — which everybody copies — is what ownership guards.
 */
export function motionArtifactUpdateDefinition(): MutationDefinition<
  typeof artifactUpdateSchema,
  { artifact: EngagementArtifact }
> {
  return {
    schema: artifactUpdateSchema,
    permission: authorizeWrite,
    invalidMessage: 'Invalid artefact change.',
    async mutate({ input, params, tx, now }) {
      const id = requiredId(params, 'Engagement artefact');
      const [existing] = await tx
        .select()
        .from(engagementArtifacts)
        .where(eq(engagementArtifacts.id, id))
        .limit(1);
      if (!existing) throw MutationError.notFound('Engagement artefact');
      const { deal } = await loadEngagement(tx, existing.engagementId);

      const [updated] = await tx
        .update(engagementArtifacts)
        .set({
          ...(input.title !== undefined ? { title: input.title } : {}),
          ...(input.body !== undefined ? { body: input.body } : {}),
          ...(input.fields !== undefined ? { fields: input.fields } : {}),
          ...(input.stage !== undefined ? { stage: input.stage } : {}),
          ...(input.status !== undefined ? { status: input.status } : {}),
          ...(input.archived !== undefined ? { archivedAt: input.archived ? now : null } : {}),
          updatedAt: now,
        })
        .where(eq(engagementArtifacts.id, existing.id))
        .returning();
      if (!updated) throw MutationError.notFound('Engagement artefact');

      return {
        data: { artifact: updated },
        activity: {
          type: 'note',
          subject: `Updated ${updated.title} on ${deal.name}`,
          accountId: deal.accountId,
          demandDealId: deal.id,
          meta: {
            action: 'engagement_artifact.updated',
            artifactId: updated.id,
            engagementId: updated.engagementId,
            fields: Object.keys(input),
            status: updated.status,
          },
        },
      };
    },
  };
}

export function motionArtifactPromoteDefinition(): MutationDefinition<
  typeof promoteSchema,
  { template: MotionTemplate; artifact: EngagementArtifact }
> {
  return {
    schema: promoteSchema,
    permission: authorizePublish,
    invalidMessage: 'Invalid promotion.',
    async mutate({ input, params, principal, tx, now }) {
      const result = await promoteArtifact(
        tx,
        viewerOf(principal),
        requiredId(params, 'Engagement artefact'),
        input,
        now,
      );

      return {
        data: { template: result.template, artifact: result.artifact },
        activity: {
          type: 'note',
          subject: `Promoted to the library: ${result.template.title} (v${result.template.version})`,
          // The account, so the loop lands on the timeline of the deal that
          // proved it rather than only in the library.
          accountId: result.engagement.deal.accountId,
          demandDealId: result.engagement.deal.id,
          meta: {
            action: 'engagement_artifact.promoted',
            artifactId: result.artifact.id,
            engagementId: result.engagement.engagement.id,
            templateId: result.template.id,
            supersedesId: result.supersedes?.id ?? null,
            slug: result.template.slug,
            version: result.template.version,
          },
        },
      };
    },
  };
}

export function motionScoreDefinition(): MutationDefinition<
  typeof scoreSchema,
  { score: QualificationScore }
> {
  return {
    schema: scoreSchema,
    permission: authorizeWrite,
    invalidMessage: 'Invalid qualification score.',
    async mutate({ input, params, principal, tx, now }) {
      const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
      await checkedTemplateId(tx, principal, input.frameworkTemplateId);
      const score = await recordScore(tx, viewerOf(principal), engagement.id, input, now);

      return {
        data: { score },
        activity: {
          type: 'note',
          // Rounded, never truncated — the same rule money follows, for the
          // same reason: this line is the only score most people will read.
          subject: `Qualified ${deal.name}: ${score.band} (${Math.round(score.basisPoints / 100)}%)`,
          accountId: deal.accountId,
          demandDealId: deal.id,
          meta: {
            action: 'qualification_score.recorded',
            engagementId: engagement.id,
            scoreId: score.id,
            basisPoints: score.basisPoints,
            band: score.band,
            tone: motionBand(score.basisPoints).tone,
          },
        },
      };
    },
  };
}

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

export function createMotionRoutes(db: Database): Hono<ApiEnv> {
  const routes = new Hono<ApiEnv>();
  const motion = new MotionService(db);

  routes.get('/api/motion', async (c) =>
    c.json(await motion.overview(viewerOf(c.get('principal')))),
  );

  routes.get('/api/motion/templates', async (c) => {
    const parsed = filtersSchema.safeParse(c.req.query());
    if (!parsed.success) {
      return c.json(
        apiError('invalid_request', 'Invalid library filter.', parsed.error.issues),
        400,
      );
    }
    const { all, ...filters } = parsed.data;
    return c.json(
      await motion.listTemplates(viewerOf(c.get('principal')), { ...filters, all: all === '1' }),
    );
  });

  routes.get('/api/motion/templates/:id', async (c) => {
    const principal = c.get('principal');
    const found = await motion.template(viewerOf(principal), c.req.param('id'));
    // A private template belonging to somebody else answers exactly as an
    // unknown id does. Anything else confirms that it exists.
    if (!found) return c.json(apiError('not_found', 'Motion template not found.'), 404);

    const owned = ownsTemplate(viewerOf(principal), found.template);
    return c.json({
      ...found,
      /** The server's judgement, so the page does not re-derive §7a in TSX. */
      canEdit: owned && found.template.usageCount === 0,
      canPublish: owned && found.template.visibility === 'private' && canPublish(principal),
    });
  });

  routes.get('/api/motion/engagements', async (c) => {
    const parsed = engagementFiltersSchema.safeParse(c.req.query());
    if (!parsed.success) {
      return c.json(
        apiError('invalid_request', 'Invalid engagement filter.', parsed.error.issues),
        400,
      );
    }
    return c.json({ engagements: await motion.listEngagements(parsed.data.status) });
  });

  routes.get('/api/motion/engagements/:id', async (c) => {
    const found = await motion.engagement(viewerOf(c.get('principal')), c.req.param('id'));
    if (!found) return c.json(apiError('not_found', 'Engagement not found.'), 404);
    return c.json(found);
  });

  routes.post('/api/motion/templates', mutation(db, motionTemplateCreateDefinition()));
  routes.patch('/api/motion/templates/:id', mutation(db, motionTemplateUpdateDefinition()));
  routes.post(
    '/api/motion/templates/:id/versions',
    mutation(db, motionTemplateVersionDefinition()),
  );
  routes.post(
    '/api/motion/templates/:id/publish',
    bodylessMutation(db, motionTemplatePublishDefinition()),
  );
  routes.delete(
    '/api/motion/templates/:id',
    bodylessMutation(db, motionTemplateArchiveDefinition()),
  );

  routes.post('/api/motion/engagements', mutation(db, motionEngagementCreateDefinition()));
  routes.patch('/api/motion/engagements/:id', mutation(db, motionEngagementUpdateDefinition()));
  routes.post(
    '/api/motion/engagements/:id/artifacts',
    mutation(db, motionArtifactCreateDefinition()),
  );
  routes.patch('/api/motion/artifacts/:id', mutation(db, motionArtifactUpdateDefinition()));
  routes.post(
    '/api/motion/artifacts/:id/promote',
    mutation(db, motionArtifactPromoteDefinition()),
  );
  routes.post(
    '/api/motion/engagements/:id/scores',
    mutation(db, motionScoreDefinition()),
  );

  return routes;
}
