/**
 * Motion — the library, the engagements, and the loop between them.
 *
 * Everything with a decision in it lives here rather than in the route module,
 * for the reason stated in AGENTS.md §4 and for one more: Piggy has to be able
 * to ask these questions in-process, the way it asks `CalendarService`. So the
 * reads take a `Database` and a `MotionViewer` — never a request, never a
 * `Principal` — and the writes take the transaction the `mutation()` chokepoint
 * already opened.
 *
 * ## The private filter is a WHERE clause, not an affordance
 *
 * `permissions.ts` says plainly that every read endpoint returns the whole book
 * because no row-level filter exists anywhere in the query layer. Motion is the
 * first exception and `visibleTemplates()` below is the whole of it. Two things
 * about it are load-bearing:
 *
 * - A private row is matched by `owner_user_id = $viewer`, which is NULL-safe
 *   by construction: `owner_user_id IS NULL` never equals anything, so an
 *   orphaned private row would be invisible to everybody but a platform admin.
 *   Postgres will not currently produce one — running the delete proves it:
 *   `motion_templates_private_has_owner_check` is evaluated on the UPDATE that
 *   `ON DELETE SET NULL` performs, so deleting the owner of a private template
 *   raises a check violation and the delete fails outright. The predicate is
 *   kept in this NULL-safe form anyway, because the day that constraint is
 *   relaxed the obvious "fix" — `owner = $me OR owner IS NULL` — hands every
 *   departed colleague's drafts to the whole workspace, and it reads like a
 *   repair for rows that had gone missing.
 * - A platform admin gets no predicate at all. That is deliberate and it is
 *   pinned by a test that says so in its name, because it looks like the filter
 *   failing open.
 *
 * ## Ownership is not the same question as capability
 *
 * `motion:write` says a person may author; ownership says whose row this is.
 * Both are required to change a template, and they fail differently on purpose:
 * a missing capability is `insufficient_permission` from the mutation
 * chokepoint before the body is even read, and a wrong owner is `not_owner`
 * from `assertTemplateWritable` once the row is known. Anything shared is
 * readable book-wide but still only its owner's to edit — a shared template is
 * published, not communal.
 */
import {
  DEMAND_OPEN_STAGES,
  DEMAND_STAGES,
  MOTION_BANDS,
  MOTION_KINDS,
  motionBand,
  motionScoreBasisPoints,
  type ArtifactStatus,
  type DemandStage,
  type EngagementStatus,
  type MotionBandTone,
  type MotionDimensionScore,
  type MotionKind,
  type MotionVisibility,
} from '@pig/core';
import type {
  Database,
  DemandDeal,
  Engagement,
  EngagementArtifact,
  MotionTemplate,
  QualificationScore,
} from '@pig/db';
import {
  accounts,
  demandDeals,
  engagementArtifacts,
  engagements,
  motionTemplates,
  qualificationScores,
} from '@pig/db';
import { and, desc, eq, ilike, inArray, isNotNull, isNull, or, sql, type SQL } from 'drizzle-orm';
import { AuthError } from '../lib/auth';
import { MutationError } from '../lib/mutation';

export type MotionTransaction = Parameters<Parameters<Database['transaction']>[0]>[0];

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/**
 * Whether a string can name a row at all.
 *
 * Every id here is a `uuid` column, so a lookup by `nope` never reaches the
 * "no such row" branch: Postgres refuses the cast with `22P02` and the request
 * leaves as a 500. The reads below therefore answer null for a malformed id,
 * which the routes already turn into the 404 an unknown id gets — the same
 * answer a private template gives, which is the point of routing both through
 * one branch. `requiredId` in `routes/motion.ts` does the same for the writes.
 */
function isUuid(id: string): boolean {
  return UUID.test(id);
}

/**
 * The subset of a `Principal` a motion read is allowed to see. A service that
 * took the whole principal would be one refactor away from consulting teams or
 * scopes, and the answer to "may I see this row" must stay these two fields.
 */
export interface MotionViewer {
  readonly userId: string;
  readonly isPlatformAdmin: boolean;
}

/** No pagination anywhere in this codebase; every read is bounded instead. */
const TEMPLATE_LIMIT = 200;
/**
 * Latest-version-per-slug is folded in memory, so the scan has to be wider than
 * the answer. Rows arrive ordered `(slug, version desc)`, which puts the
 * winning version of a slug first and makes a truncated scan lose whole
 * lineages rather than return a stale version of one.
 */
const TEMPLATE_SCAN_LIMIT = 400;
const ENGAGEMENT_LIMIT = 200;
const ARTIFACT_LIMIT = 400;
const SCORE_LIMIT = 200;
/** Enough to show a trend on the Motion home without being a second page. */
const OVERVIEW_LIMIT = 8;

// ---------------------------------------------------------------------- views

export interface MotionTemplateSummary {
  id: string;
  kind: MotionKind;
  slug: string;
  version: number;
  title: string;
  summary: string;
  stage: DemandStage;
  visibility: MotionVisibility;
  ownerUserId: string | null;
  supersedesId: string | null;
  originArtifactId: string | null;
  isSystem: boolean;
  usageCount: number;
  archivedAt: Date | null;
  createdAt: Date;
  updatedAt: Date;
}

export interface MotionTemplateDetail extends MotionTemplateSummary {
  body: string;
  fields: Record<string, unknown> | null;
}

export interface MotionArtifactView {
  id: string;
  engagementId: string;
  templateId: string | null;
  kind: MotionKind;
  stage: DemandStage;
  title: string;
  body: string;
  fields: Record<string, unknown> | null;
  status: ArtifactStatus;
  authoredByUserId: string | null;
  promotedTemplateId: string | null;
  archivedAt: Date | null;
  createdAt: Date;
  updatedAt: Date;
}

export interface MotionScoreView {
  id: string;
  engagementId: string;
  frameworkTemplateId: string | null;
  dimensions: MotionDimensionScore[];
  basisPoints: number;
  band: string;
  /** Derived, never stored: the palette must not be persisted alongside the score. */
  tone: MotionBandTone;
  note: string | null;
  scoredByUserId: string | null;
  scoredAt: Date;
}

export interface MotionEngagementSummary {
  id: string;
  demandDealId: string;
  dealName: string | null;
  /** The deal's stage. An engagement has no stage of its own — that is the point. */
  stage: DemandStage | null;
  accountId: string | null;
  accountName: string | null;
  status: EngagementStatus;
  summary: string | null;
  ownerUserId: string | null;
  playbookTemplateId: string | null;
  openedAt: Date;
  closedAt: Date | null;
  createdAt: Date;
  updatedAt: Date;
  artifactCount: number;
  latestScore: MotionScoreView | null;
}

export interface MotionStageCoverage {
  stage: DemandStage;
  engagements: number;
  templates: number;
}

export interface MotionKindCoverage {
  kind: MotionKind;
  templates: number;
  shared: number;
}

export interface MotionPromotion {
  templateId: string;
  slug: string;
  title: string;
  kind: MotionKind;
  version: number;
  promotedAt: Date;
  artifactId: string | null;
  engagementId: string | null;
  demandDealId: string | null;
  dealName: string | null;
}

export interface MotionOverview {
  totals: {
    templates: number;
    shared: number;
    private: number;
    engagements: number;
    open: number;
    promotions: number;
  };
  /** Always the eight open demand stages, in order, empty ones included. */
  stages: MotionStageCoverage[];
  /** Always the nine kinds, in `MOTION_KINDS` order, empty ones included. */
  library: MotionKindCoverage[];
  engagements: MotionEngagementSummary[];
  promotions: MotionPromotion[];
}

export interface MotionTemplateList {
  templates: MotionTemplateSummary[];
  /** The scan hit its bound; the library is wider than the answer. */
  truncated: boolean;
}

export interface MotionTemplateDetailView {
  template: MotionTemplateDetail;
  /** Every version of this slug the viewer may see, oldest first. */
  lineage: MotionTemplateSummary[];
}

export interface MotionEngagementDetail {
  engagement: MotionEngagementSummary;
  /** One entry per demand stage, in `DEMAND_STAGES` order, empty ones included. */
  stages: { stage: DemandStage; artifacts: MotionArtifactView[] }[];
  scores: MotionScoreView[];
  playbook: MotionTemplateSummary | null;
}

export interface MotionTemplateFilters {
  kind?: MotionKind;
  stage?: DemandStage;
  visibility?: MotionVisibility;
  q?: string;
  /** Every version, not just the newest of each lineage. */
  all?: boolean;
}

// ------------------------------------------------------------------- mappers

export function motionTemplateSummary(row: MotionTemplate): MotionTemplateSummary {
  return {
    id: row.id,
    kind: row.kind,
    slug: row.slug,
    version: row.version,
    title: row.title,
    summary: row.summary,
    stage: row.stage,
    visibility: row.visibility,
    ownerUserId: row.ownerUserId,
    supersedesId: row.supersedesId,
    originArtifactId: row.originArtifactId,
    isSystem: row.isSystem,
    usageCount: row.usageCount,
    archivedAt: row.archivedAt,
    createdAt: row.createdAt,
    updatedAt: row.updatedAt,
  };
}

export function motionTemplateDetail(row: MotionTemplate): MotionTemplateDetail {
  return { ...motionTemplateSummary(row), body: row.body, fields: row.fields };
}

export function motionArtifactView(row: EngagementArtifact): MotionArtifactView {
  return {
    id: row.id,
    engagementId: row.engagementId,
    templateId: row.templateId,
    kind: row.kind,
    stage: row.stage,
    title: row.title,
    body: row.body,
    fields: row.fields,
    status: row.status,
    authoredByUserId: row.authoredByUserId,
    promotedTemplateId: row.promotedTemplateId,
    archivedAt: row.archivedAt,
    createdAt: row.createdAt,
    updatedAt: row.updatedAt,
  };
}

export function motionScoreView(row: QualificationScore): MotionScoreView {
  return {
    id: row.id,
    engagementId: row.engagementId,
    frameworkTemplateId: row.frameworkTemplateId,
    dimensions: row.dimensions as unknown as MotionDimensionScore[],
    basisPoints: row.basisPoints,
    band: row.band,
    tone: toneForBand(row.band, row.basisPoints),
    note: row.note,
    scoredByUserId: row.scoredByUserId,
    scoredAt: row.scoredAt,
  };
}

/**
 * The colour that goes with the label that was stored, not with the label the
 * score would earn today.
 *
 * `band` is persisted and `tone` is not, so moving a boundary in
 * `MOTION_BANDS` would otherwise paint every historic row's old label in the
 * new colour — "Qualified" in the danger red. Matching on the stored label
 * keeps the two halves of one judgement together; a label from a band that no
 * longer exists falls back to the score, which is the only other thing we have.
 */
function toneForBand(band: string, basisPoints: number): MotionBandTone {
  return MOTION_BANDS.find((row) => row.label === band)?.tone ?? motionBand(basisPoints).tone;
}

// -------------------------------------------------------------- the filter

/**
 * The row-level read filter — see the file header for why it is spelled this
 * way and not the other, more obvious way.
 *
 * `undefined` means "no predicate", which `and()` drops, so a platform admin's
 * query is the unfiltered one.
 */
export function visibleTemplates(viewer: MotionViewer): SQL | undefined {
  if (viewer.isPlatformAdmin) return undefined;
  return or(
    eq(motionTemplates.visibility, 'shared'),
    and(
      eq(motionTemplates.visibility, 'private'),
      eq(motionTemplates.ownerUserId, viewer.userId),
    ),
  );
}

/** Whose row is this? Shared does not mean communal — publishing is not donating. */
export function ownsTemplate(
  viewer: MotionViewer,
  row: { ownerUserId: string | null },
): boolean {
  return viewer.isPlatformAdmin || (row.ownerUserId !== null && row.ownerUserId === viewer.userId);
}

/**
 * A slug is the identity of a lineage, so it is derived from the title only
 * when the caller supplies nothing better. Two unrelated templates given the
 * same title therefore land in the same lineage — which is why every write path
 * that derives one also accepts an explicit `slug`.
 */
export function motionSlug(value: string): string {
  const slug = value
    .toLowerCase()
    .normalize('NFKD')
    // NFKD leaves the combining mark behind as its own character, which the
    // next rule would turn into a hyphen: "Café strategy" becomes
    // `caf-e-strategy` and the lineage of an accented title splits a word.
    .replace(/\p{M}/gu, '')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 80)
    .replace(/-+$/g, '');
  return slug || 'untitled';
}

/**
 * `%` and `_` are LIKE wildcards, and `?q=` arrives from a URL somebody pasted.
 * Unescaped, a search for `100%` matches every template in the book and reads
 * as the filter being ignored. Piggy escapes the same way, in `likeFragment`.
 */
function likeFragment(value: string): string {
  return `%${value.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
}

// --------------------------------------------------------------------- reads

export class MotionService {
  constructor(private readonly db: Database) {}

  /**
   * The Motion home: is the motion actually repeating?
   *
   * One grouped query answers the library half — counting in SQL rather than
   * counting rows the endpoint also has to fetch, because the tile must stay
   * exact when the list below it truncates.
   */
  async overview(viewer: MotionViewer): Promise<MotionOverview> {
    const [library, byStage, byStatus, live, promotions, promotionTotal] = await Promise.all([
      this.db
        .select({
          kind: motionTemplates.kind,
          stage: motionTemplates.stage,
          visibility: motionTemplates.visibility,
          total: sql<number>`count(*)::int`,
        })
        .from(motionTemplates)
        .where(and(isNull(motionTemplates.archivedAt), visibleTemplates(viewer)))
        .groupBy(motionTemplates.kind, motionTemplates.stage, motionTemplates.visibility),
      this.db
        .select({ stage: demandDeals.stage, total: sql<number>`count(*)::int` })
        .from(engagements)
        .innerJoin(demandDeals, eq(demandDeals.id, engagements.demandDealId))
        .where(eq(engagements.status, 'open'))
        .groupBy(demandDeals.stage),
      this.db
        .select({ status: engagements.status, total: sql<number>`count(*)::int` })
        .from(engagements)
        .groupBy(engagements.status),
      this.engagementSummaries(eq(engagements.status, 'open'), OVERVIEW_LIMIT),
      this.promotions(viewer, OVERVIEW_LIMIT),
      // Counted in SQL rather than taken from the list above it, which is
      // capped at `OVERVIEW_LIMIT`. Promotions is the one figure on that page
      // whose job is to show the loop closing, and a tile pegged at 8 forever
      // is worse than no tile at all.
      this.db
        .select({ total: sql<number>`count(*)::int` })
        .from(motionTemplates)
        .where(
          and(
            isNotNull(motionTemplates.originArtifactId),
            isNull(motionTemplates.archivedAt),
            visibleTemplates(viewer),
          ),
        ),
    ]);

    const templatesByStage = new Map<string, number>();
    const templatesByKind = new Map<string, { total: number; shared: number }>();
    let templates = 0;
    let shared = 0;
    for (const row of library) {
      templates += row.total;
      if (row.visibility === 'shared') shared += row.total;
      templatesByStage.set(row.stage, (templatesByStage.get(row.stage) ?? 0) + row.total);
      const kind = templatesByKind.get(row.kind) ?? { total: 0, shared: 0 };
      kind.total += row.total;
      if (row.visibility === 'shared') kind.shared += row.total;
      templatesByKind.set(row.kind, kind);
    }

    const engagementsByStage = new Map(byStage.map((row) => [row.stage, row.total]));
    const engagementTotal = byStatus.reduce((sum, row) => sum + row.total, 0);
    const openTotal = byStatus.find((row) => row.status === 'open')?.total ?? 0;

    return {
      totals: {
        templates,
        shared,
        private: templates - shared,
        engagements: engagementTotal,
        open: openTotal,
        promotions: promotionTotal[0]?.total ?? 0,
      },
      stages: DEMAND_OPEN_STAGES.map((stage) => ({
        stage,
        engagements: engagementsByStage.get(stage) ?? 0,
        templates: templatesByStage.get(stage) ?? 0,
      })),
      library: MOTION_KINDS.map((kind) => ({
        kind,
        templates: templatesByKind.get(kind)?.total ?? 0,
        shared: templatesByKind.get(kind)?.shared ?? 0,
      })),
      engagements: live,
      promotions,
    };
  }

  async listTemplates(
    viewer: MotionViewer,
    filters: MotionTemplateFilters = {},
  ): Promise<MotionTemplateList> {
    const search = filters.q?.trim();
    const fragment = search ? likeFragment(search) : null;
    const rows = await this.db
      .select()
      .from(motionTemplates)
      .where(
        and(
          isNull(motionTemplates.archivedAt),
          visibleTemplates(viewer),
          filters.kind ? eq(motionTemplates.kind, filters.kind) : undefined,
          filters.stage ? eq(motionTemplates.stage, filters.stage) : undefined,
          filters.visibility ? eq(motionTemplates.visibility, filters.visibility) : undefined,
          fragment
            ? or(
                ilike(motionTemplates.title, fragment),
                ilike(motionTemplates.summary, fragment),
                ilike(motionTemplates.slug, fragment),
              )
            : undefined,
        ),
      )
      .orderBy(motionTemplates.slug, desc(motionTemplates.version))
      .limit(TEMPLATE_SCAN_LIMIT);

    const kept = filters.all ? rows : newestPerSlug(rows);
    kept.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
    return {
      templates: kept.slice(0, TEMPLATE_LIMIT).map(motionTemplateSummary),
      truncated: rows.length === TEMPLATE_SCAN_LIMIT || kept.length > TEMPLATE_LIMIT,
    };
  }

  /**
   * One template and its lineage. Null rather than an empty detail when the
   * viewer may not see it: the route turns that into the same 404 an unknown id
   * produces, so a private title cannot be confirmed by probing for one.
   */
  async template(viewer: MotionViewer, id: string): Promise<MotionTemplateDetailView | null> {
    if (!isUuid(id)) return null;
    const [row] = await this.db
      .select()
      .from(motionTemplates)
      .where(and(eq(motionTemplates.id, id), visibleTemplates(viewer)))
      .limit(1);
    if (!row) return null;

    const lineage = await this.db
      .select()
      .from(motionTemplates)
      .where(and(eq(motionTemplates.slug, row.slug), visibleTemplates(viewer)))
      .orderBy(motionTemplates.version)
      .limit(TEMPLATE_LIMIT);

    return { template: motionTemplateDetail(row), lineage: lineage.map(motionTemplateSummary) };
  }

  async listEngagements(status?: EngagementStatus): Promise<MotionEngagementSummary[]> {
    return this.engagementSummaries(
      status ? eq(engagements.status, status) : undefined,
      ENGAGEMENT_LIMIT,
    );
  }

  async engagement(viewer: MotionViewer, id: string): Promise<MotionEngagementDetail | null> {
    if (!isUuid(id)) return null;
    const [summary] = await this.engagementSummaries(eq(engagements.id, id), 1);
    if (!summary) return null;

    const [artifacts, scores, playbook] = await Promise.all([
      this.db
        .select()
        .from(engagementArtifacts)
        .where(
          and(
            eq(engagementArtifacts.engagementId, id),
            isNull(engagementArtifacts.archivedAt),
          ),
        )
        .orderBy(desc(engagementArtifacts.updatedAt))
        .limit(ARTIFACT_LIMIT),
      this.db
        .select()
        .from(qualificationScores)
        .where(eq(qualificationScores.engagementId, id))
        .orderBy(desc(qualificationScores.scoredAt))
        .limit(SCORE_LIMIT),
      summary.playbookTemplateId
        ? this.db
            .select()
            .from(motionTemplates)
            .where(
              and(
                eq(motionTemplates.id, summary.playbookTemplateId),
                visibleTemplates(viewer),
              ),
            )
            .limit(1)
        : Promise.resolve([]),
    ]);

    return {
      engagement: summary,
      stages: DEMAND_STAGES.map((stage) => ({
        stage,
        artifacts: artifacts.filter((row) => row.stage === stage).map(motionArtifactView),
      })),
      scores: scores.map(motionScoreView),
      playbook: playbook[0] ? motionTemplateSummary(playbook[0]) : null,
    };
  }

  /**
   * What got easier this quarter. A promoted version is the only row in the
   * library carrying an `origin_artifact_id`, so the loop needs no second table
   * to be reportable.
   */
  async promotions(viewer: MotionViewer, limit: number): Promise<MotionPromotion[]> {
    const rows = await this.db
      .select({
        template: motionTemplates,
        artifactId: engagementArtifacts.id,
        engagementId: engagementArtifacts.engagementId,
        demandDealId: engagements.demandDealId,
        dealName: demandDeals.name,
      })
      .from(motionTemplates)
      .leftJoin(
        engagementArtifacts,
        eq(engagementArtifacts.id, motionTemplates.originArtifactId),
      )
      .leftJoin(engagements, eq(engagements.id, engagementArtifacts.engagementId))
      .leftJoin(demandDeals, eq(demandDeals.id, engagements.demandDealId))
      .where(
        and(
          isNotNull(motionTemplates.originArtifactId),
          isNull(motionTemplates.archivedAt),
          visibleTemplates(viewer),
        ),
      )
      .orderBy(desc(motionTemplates.createdAt))
      .limit(limit);

    return rows.map((row) => ({
      templateId: row.template.id,
      slug: row.template.slug,
      title: row.template.title,
      kind: row.template.kind,
      version: row.template.version,
      promotedAt: row.template.createdAt,
      artifactId: row.artifactId,
      engagementId: row.engagementId,
      demandDealId: row.demandDealId,
      dealName: row.dealName,
    }));
  }

  /**
   * The engagement list shape, used by the list, the detail and the home page
   * so that all three agree about what an engagement is.
   */
  private async engagementSummaries(
    where: SQL | undefined,
    limit: number,
  ): Promise<MotionEngagementSummary[]> {
    const rows = await this.db
      .select({
        engagement: engagements,
        dealName: demandDeals.name,
        stage: demandDeals.stage,
        accountId: demandDeals.accountId,
        accountName: accounts.name,
      })
      .from(engagements)
      .innerJoin(demandDeals, eq(demandDeals.id, engagements.demandDealId))
      .leftJoin(accounts, eq(accounts.id, demandDeals.accountId))
      .where(where)
      .orderBy(desc(engagements.openedAt))
      .limit(limit);
    if (rows.length === 0) return [];

    const ids = rows.map((row) => row.engagement.id);
    const [counts, scores] = await Promise.all([
      this.db
        .select({
          engagementId: engagementArtifacts.engagementId,
          total: sql<number>`count(*)::int`,
        })
        .from(engagementArtifacts)
        .where(
          and(
            inArray(engagementArtifacts.engagementId, ids),
            isNull(engagementArtifacts.archivedAt),
          ),
        )
        .groupBy(engagementArtifacts.engagementId),
      // `DISTINCT ON`, because folding a `scored_at desc` scan in memory drops
      // engagements rather than truncating a list. A hundred engagements
      // carrying three scores each can spend the whole 200-row budget on the
      // sixty most recently scored, and every other row then renders "never
      // scored" beside an exact artefact count, with nothing anywhere saying
      // so. Postgres requires the ORDER BY to lead with the distinct
      // expression, which is why `engagement_id` comes first; that is also the
      // leading column of `qualification_scores_engagement_idx`, so this reads
      // straight off the index. The result is bounded by `ids.length` and the
      // limit is left as a cap rather than a budget.
      this.db
        .selectDistinctOn([qualificationScores.engagementId])
        .from(qualificationScores)
        .where(inArray(qualificationScores.engagementId, ids))
        .orderBy(qualificationScores.engagementId, desc(qualificationScores.scoredAt))
        .limit(SCORE_LIMIT),
    ]);

    const countById = new Map(counts.map((row) => [row.engagementId, row.total]));
    const latest = new Map<string, QualificationScore>(
      scores.map((score) => [score.engagementId, score]),
    );

    return rows.map(({ engagement, dealName, stage, accountId, accountName }) => ({
      id: engagement.id,
      demandDealId: engagement.demandDealId,
      dealName,
      stage,
      accountId,
      accountName,
      status: engagement.status,
      summary: engagement.summary,
      ownerUserId: engagement.ownerUserId,
      playbookTemplateId: engagement.playbookTemplateId,
      openedAt: engagement.openedAt,
      closedAt: engagement.closedAt,
      createdAt: engagement.createdAt,
      updatedAt: engagement.updatedAt,
      artifactCount: countById.get(engagement.id) ?? 0,
      latestScore: (() => {
        const score = latest.get(engagement.id);
        return score ? motionScoreView(score) : null;
      })(),
    }));
  }
}

/** Rows arrive `(slug asc, version desc)`, so the first sighting of a slug wins. */
function newestPerSlug(rows: readonly MotionTemplate[]): MotionTemplate[] {
  const seen = new Set<string>();
  const kept: MotionTemplate[] = [];
  for (const row of rows) {
    if (seen.has(row.slug)) continue;
    seen.add(row.slug);
    kept.push(row);
  }
  return kept;
}

// -------------------------------------------------------------------- writes

/**
 * Load a template for a write, applying the read filter first.
 *
 * A row the viewer cannot see is a 404, not a 403: telling somebody that a
 * template they may not read exists is the leak, and the status code is where
 * it would happen.
 */
export async function loadTemplateForWrite(
  tx: MotionTransaction,
  viewer: MotionViewer,
  id: string,
): Promise<MotionTemplate> {
  return templateForWrite(tx, viewer, id, false);
}

/**
 * The same read, holding the row until this transaction commits.
 *
 * `usage_count` is the whole of §7a, and under READ COMMITTED reading it
 * without the lock is a decision taken about a row somebody else is already
 * changing. Two requests against a template at zero: an instantiation copies
 * the body and increments the count, while a PATCH that read zero a moment
 * earlier blocks on the row lock and then applies anyway — leaving an artefact
 * whose `template_id` names a template that no longer contains what it copied,
 * which is the provenance failure the rule exists to prevent.
 *
 * Both sides therefore take this lock, and locking only the edit does not
 * work: the copy has to hold the row until its increment commits, or it is
 * still reading a body that is about to change. Whichever transaction locks
 * first serialises the other, and a blocked locking read re-fetches the
 * committed tuple — so the edit honestly sees `usage_count = 1` and answers
 * `409 template_in_use`, or the instantiation honestly copies the edited body.
 *
 * `no key update` rather than `update`, because the row is a foreign-key
 * target — `engagement_artifacts.template_id` — and nothing here is a reason
 * to block an insert that merely references it.
 */
export async function lockTemplateForWrite(
  tx: MotionTransaction,
  viewer: MotionViewer,
  id: string,
): Promise<MotionTemplate> {
  return templateForWrite(tx, viewer, id, true);
}

/** One predicate for both, so the lock cannot drift away from the read filter. */
async function templateForWrite(
  tx: MotionTransaction,
  viewer: MotionViewer,
  id: string,
  lock: boolean,
): Promise<MotionTemplate> {
  const query = tx
    .select()
    .from(motionTemplates)
    .where(and(eq(motionTemplates.id, id), visibleTemplates(viewer)))
    .limit(1);
  const [row] = lock ? await query.for('no key update') : await query;
  if (!row) throw MutationError.notFound('Motion template');
  return row;
}

/** Owner or platform admin. Nobody else, whatever their role. */
export function assertTemplateWritable(viewer: MotionViewer, row: MotionTemplate): void {
  if (ownsTemplate(viewer, row)) return;
  throw new AuthError(
    'This template belongs to someone else. Fork it into a new version instead.',
    403,
    'not_owner',
  );
}

/**
 * §7a — a used template is never edited in place.
 *
 * The refusal names the fix, because the caller is not doing anything wrong:
 * they want a change, and a new version is how this feature spells one. A live
 * engagement whose template changed underneath it has lost the provenance that
 * makes a later promotion mean anything.
 */
export function assertTemplateEditable(row: MotionTemplate): void {
  if (row.usageCount === 0) return;
  throw new MutationError(
    'template_in_use',
    `This template has been used ${row.usageCount} time(s), so it cannot be edited in place. POST /api/motion/templates/${row.id}/versions to publish a new version instead.`,
    409,
  );
}

/**
 * The highest version number in a lineage, locked for the rest of the
 * transaction — and nothing else.
 *
 * This query is deliberately unfiltered, and deliberately returns no content.
 * The version number has to be computed against the *true* newest row or the
 * `(slug, version)` unique constraint collides, but a row the caller may not
 * read must never supply a title, a summary or an id: promotion used to fall
 * back to `supersedes.summary`, which copied a private draft's summary
 * verbatim into the shared library for anyone holding `motion:publish`.
 *
 * `FOR UPDATE` is what makes two concurrent version allocations queue rather
 * than both compute the same number and hand the loser a 500 from the unique
 * constraint. There is nothing to lock for the first version of a slug, which
 * is why the callers also map the unique violation to a 409.
 */
export async function lockNewestVersion(
  tx: MotionTransaction,
  slug: string,
): Promise<{ version: number } | null> {
  const [row] = await tx
    .select({ version: motionTemplates.version })
    .from(motionTemplates)
    .where(eq(motionTemplates.slug, slug))
    .orderBy(desc(motionTemplates.version))
    .limit(1)
    .for('update');
  return row ?? null;
}

/**
 * The newest row in a lineage *that this viewer may read*, which is what a new
 * version may honestly claim to supersede. See `lockNewestVersion` for why the
 * version number does not come from here.
 */
export async function newestVisibleInLineage(
  tx: MotionTransaction,
  viewer: MotionViewer,
  slug: string,
): Promise<MotionTemplate | null> {
  const [row] = await tx
    .select()
    .from(motionTemplates)
    .where(and(eq(motionTemplates.slug, slug), visibleTemplates(viewer)))
    .orderBy(desc(motionTemplates.version))
    .limit(1);
  return row ?? null;
}

/**
 * Two callers raced for one `(slug, version)`. The loser gets a 409 naming the
 * retry rather than the 500 a driver error becomes at `app.onError`, which
 * tells the caller nothing and rolls back an activity row they never saw.
 */
export function versionConflict(error: unknown): never {
  if (typeof error === 'object' && error !== null && (error as { code?: string }).code === '23505') {
    throw new MutationError(
      'template_version_conflict',
      'Somebody else added a version of this template a moment ago. Reload and try again.',
      409,
    );
  }
  throw error;
}

export interface EngagementContext {
  engagement: Engagement;
  deal: DemandDeal;
}

/** An engagement is only ever addressed with its deal, because the audit row needs the account. */
export async function loadEngagement(
  tx: MotionTransaction,
  id: string,
): Promise<EngagementContext> {
  const [row] = await tx
    .select({ engagement: engagements, deal: demandDeals })
    .from(engagements)
    .innerJoin(demandDeals, eq(demandDeals.id, engagements.demandDealId))
    .where(eq(engagements.id, id))
    .limit(1);
  if (!row) throw MutationError.notFound('Engagement');
  return row;
}

export interface InstantiateInput {
  engagementId: string;
  templateId?: string;
  kind?: MotionKind;
  stage?: DemandStage;
  title?: string;
  body?: string;
  fields?: Record<string, unknown> | null;
  status?: ArtifactStatus;
}

/**
 * Instantiate a template into an engagement, or write an artifact from scratch.
 *
 * The copy is a copy, not a reference: the artifact carries its own body so
 * that editing it cannot reach back into the library, and `template_id` records
 * only where it came from. `usage_count` moves in the same transaction, which
 * is what closes the template to further in-place edits from this moment on.
 */
export async function instantiateArtifact(
  tx: MotionTransaction,
  viewer: MotionViewer,
  input: InstantiateInput,
  now: Date,
): Promise<{ artifact: EngagementArtifact; template: MotionTemplate | null }> {
  // Locked, not merely read: the body copied below has to be the body the
  // `usage_count` increment at the end of this function is counting, or a
  // concurrent edit rewrites the template between the two.
  const template = input.templateId
    ? await lockTemplateForWrite(tx, viewer, input.templateId)
    : null;
  // Every read path hides an archived template, so instantiating one can only
  // happen by holding an id from before it was archived — and it would leave
  // the row with a usage count that refuses an edit if it is ever restored.
  if (template?.archivedAt) {
    throw new MutationError(
      'template_archived',
      'This template has been archived. Instantiate a current version of it instead.',
      409,
    );
  }

  const kind = input.kind ?? template?.kind;
  const stage = input.stage ?? template?.stage;
  const title = input.title ?? template?.title;
  const body = input.body ?? template?.body;
  if (!kind || !stage || !title || body === undefined) {
    throw new MutationError(
      'invalid_request',
      'An artefact needs a template to instantiate, or a kind, stage, title and body of its own.',
      400,
    );
  }

  const [artifact] = await tx
    .insert(engagementArtifacts)
    .values({
      engagementId: input.engagementId,
      templateId: template?.id ?? null,
      kind,
      stage,
      title,
      body,
      fields: input.fields ?? template?.fields ?? null,
      status: input.status ?? 'draft',
      authoredByUserId: viewer.userId,
      createdAt: now,
      updatedAt: now,
    })
    .returning();
  if (!artifact) throw new Error('Engagement artefact insert returned no row');

  if (template) {
    await tx
      .update(motionTemplates)
      // The increment is computed by the database, not in JS from the row read
      // above. `READ COMMITTED` lets two instantiations of one template both
      // read the same count and both write the same successor, so a JS `+ 1`
      // loses a use — and the count is the only thing holding §7a shut.
      .set({ usageCount: sql`${motionTemplates.usageCount} + 1`, updatedAt: now })
      .where(eq(motionTemplates.id, template.id));
  }

  return { artifact, template };
}

/**
 * Which lineage a promotion lands in when the caller did not name one.
 *
 * The artifact's source template is the honest answer, but reading it applies
 * the visibility filter, and a lead promoting the finished work of an
 * engagement instantiated from somebody else's private template would
 * otherwise get `404 Motion template not found` on a request whose subject is
 * an artifact that plainly exists. Refusing with the fix named beats both that
 * 404 and the alternative of silently promoting into a lineage the caller
 * cannot see the shape of.
 */
async function lineageOf(
  tx: MotionTransaction,
  viewer: MotionViewer,
  artifact: EngagementArtifact,
): Promise<string> {
  if (!artifact.templateId) return motionSlug(artifact.title);

  const [source] = await tx
    .select({ slug: motionTemplates.slug })
    .from(motionTemplates)
    .where(and(eq(motionTemplates.id, artifact.templateId), visibleTemplates(viewer)))
    .limit(1);
  if (source) return source.slug;

  throw new MutationError(
    'source_template_unreadable',
    'This artefact came from a template you cannot read, so its lineage cannot be inferred. Promote it again with an explicit slug.',
    409,
  );
}

export interface PromoteInput {
  slug?: string;
  title?: string;
  summary?: string;
}

export interface PromotionResult {
  artifact: EngagementArtifact;
  template: MotionTemplate;
  supersedes: MotionTemplate | null;
  engagement: EngagementContext;
}

/**
 * §7b — promotion is the loop.
 *
 * The new row is version `previous + 1` of the lineage, pointing back at its
 * predecessor and at the artifact that proved it, and it is shared: a promotion
 * that landed privately would be a promotion nobody could copy, which is the
 * only thing promotion is for.
 *
 * Two refusals, both 409 and both deliberate. An artifact promoted twice would
 * fork its own lineage silently, and a draft promoted at all would put
 * unfinished work in the place the next deployment copies from.
 */
export async function promoteArtifact(
  tx: MotionTransaction,
  viewer: MotionViewer,
  artifactId: string,
  input: PromoteInput,
  now: Date,
): Promise<PromotionResult> {
  const [artifact] = await tx
    .select()
    .from(engagementArtifacts)
    // Locked before the two refusals below, because both of them read state
    // this transaction is about to change. Without it, two promotions of one
    // artifact with different slugs both pass `already_promoted`, both insert,
    // and one of the two templates is left in the shared library pointing at
    // an artifact that does not name it — the silent lineage fork the guard
    // exists to prevent.
    .where(eq(engagementArtifacts.id, artifactId))
    .limit(1)
    .for('update');
  if (!artifact) throw MutationError.notFound('Engagement artefact');

  if (artifact.archivedAt) {
    throw new MutationError(
      'artifact_archived',
      'This artefact has been archived. Restore it before promoting it into the library.',
      409,
    );
  }
  if (artifact.promotedTemplateId) {
    throw new MutationError(
      'already_promoted',
      'This artefact is already in the library. Promote a later revision instead.',
      409,
    );
  }
  if (artifact.status !== 'final') {
    throw new MutationError(
      'artifact_not_final',
      'Only a final artefact may be promoted — the library is what the next deployment copies.',
      409,
    );
  }

  const engagement = await loadEngagement(tx, artifact.engagementId);

  const slug = input.slug ? motionSlug(input.slug) : await lineageOf(tx, viewer, artifact);
  // The number comes from the whole lineage and the row comes from the half of
  // it this viewer may read — see `lockNewestVersion`. They are different
  // questions and answering both from one query is how a private draft's
  // summary reached the shared library.
  const newest = await lockNewestVersion(tx, slug);
  const supersedes = await newestVisibleInLineage(tx, viewer, slug);

  const [template] = await tx
    .insert(motionTemplates)
    .values({
      kind: artifact.kind,
      slug,
      version: (newest?.version ?? 0) + 1,
      title: input.title ?? artifact.title,
      summary: input.summary ?? supersedes?.summary ?? artifact.title,
      body: artifact.body,
      fields: artifact.fields,
      stage: artifact.stage,
      visibility: 'shared',
      ownerUserId: viewer.userId,
      supersedesId: supersedes?.id ?? null,
      originArtifactId: artifact.id,
      isSystem: false,
      createdAt: now,
      updatedAt: now,
    })
    .returning()
    .catch(versionConflict);
  if (!template) throw new Error('Promoted motion template insert returned no row');

  const [promoted] = await tx
    .update(engagementArtifacts)
    .set({ promotedTemplateId: template.id, updatedAt: now })
    .where(eq(engagementArtifacts.id, artifact.id))
    .returning();
  if (!promoted) throw MutationError.notFound('Engagement artefact');

  return { artifact: promoted, template, supersedes, engagement };
}

export interface ScoreInput {
  dimensions: MotionDimensionScore[];
  frameworkTemplateId?: string | null;
  note?: string | null;
}

/**
 * Append a qualification score.
 *
 * The score is computed here from the dimensions, never taken from the body: a
 * number a client can post is a number somebody can make true afterwards, and
 * the whole reason this table is append-only is that the movement of the score
 * is the evidence.
 */
export async function recordScore(
  tx: MotionTransaction,
  viewer: MotionViewer,
  engagementId: string,
  input: ScoreInput,
  now: Date,
): Promise<QualificationScore> {
  const basisPoints = motionScoreBasisPoints(input.dimensions);
  const [row] = await tx
    .insert(qualificationScores)
    .values({
      engagementId,
      frameworkTemplateId: input.frameworkTemplateId ?? null,
      // Drizzle's jsonb `$type` will not accept a readonly-property interface,
      // so the cast happens once, here, at the write boundary.
      dimensions: input.dimensions as unknown as Record<string, unknown>[],
      basisPoints,
      band: motionBand(basisPoints).label,
      note: input.note ?? null,
      scoredByUserId: viewer.userId,
      scoredAt: now,
      createdAt: now,
      updatedAt: now,
    })
    .returning();
  if (!row) throw new Error('Qualification score insert returned no row');
  return row;
}
