/**
 * Motion — the library, the engagements, and the loop between them.
 *
 * Four tables, and three of the decisions in them are load-bearing.
 *
 * **A used template is never edited in place.** `usage_count` is incremented
 * when a template is instantiated into an engagement, and the API refuses a
 * `PATCH` once it is above zero — a new version row is written instead,
 * carrying `supersedes_id` back to its predecessor and `origin_artifact_id`
 * forward to the artifact that proved it. A live engagement whose template
 * changed underneath it has lost its provenance, and provenance is the only
 * thing that makes "this came from playbook v3" mean anything a quarter later.
 * That is also why `(slug, version)` is unique: the seed writes
 * `onConflictDoNothing({ target: [slug, version] })`, which is a silent no-op
 * without a constraint to conflict on, and that has already duplicated seed
 * data twice in this codebase.
 *
 * **`visibility = 'private'` is a real access rule, and it is this codebase's
 * first row-level filter.** `permissions.ts` says plainly that every read
 * endpoint returns the whole book, because no row-level team filter exists
 * anywhere in the query layer. Motion is a deliberate exception, stated here
 * so it cannot be discovered by surprise: a draft proposal for a live deal is
 * not the same object as a contract, and a library nobody can draft in
 * privately becomes a library nobody drafts in. `shared` is book-wide on
 * `book:read` exactly like everything else; `private` is readable and writable
 * by `owner_user_id` and by a platform admin, and by nobody else.
 *
 * The CHECK below refuses a private row with no owner, because such a row is
 * readable by nobody and writable by nobody — a leak the day someone "fixes"
 * the query that appears to be dropping rows. It also, measured by running the
 * delete rather than by reading the DDL, makes `ON DELETE SET NULL` on
 * `owner_user_id` unreachable for a private row: Postgres evaluates the CHECK
 * on the UPDATE the referential action performs, so deleting a user who owns
 * one raises `motion_templates_private_has_owner_check` and the delete fails.
 * Private authorship therefore blocks a user delete today, and whoever adds a
 * member-removal path has to archive or reassign those rows first. The read
 * query still treats a private row with a null owner as invisible to everyone
 * but a platform admin, so that relaxing the CHECK cannot quietly publish
 * them — do not write that filter as `owner_user_id = $me OR owner_user_id IS
 * NULL`.
 *
 * **`qualification_scores` is append-only.** Never updated, never deleted. The
 * movement of a score across an engagement is the evidence that qualification
 * happened at all; a mutable current score is a number somebody can make true
 * afterwards. Everything else archives rather than deletes, as elsewhere in
 * PIG.
 *
 * The FK cycle between `motion_templates.origin_artifact_id` and
 * `engagement_artifacts.template_id` is intentional — it is the loop — and it
 * is why migration `0015_motion.sql` is hand-written, adding one of the two
 * constraints in a separate `ALTER TABLE` after both tables exist.
 */
import { sql } from 'drizzle-orm';
import {
  boolean,
  check,
  index,
  integer,
  jsonb,
  pgTable,
  text,
  timestamp,
  unique,
  uuid,
  type AnyPgColumn,
} from 'drizzle-orm/pg-core';
import {
  ARTIFACT_STATUSES,
  DEMAND_STAGES,
  ENGAGEMENT_STATUSES,
  MOTION_BASIS_POINTS_MAX,
  MOTION_KINDS,
  MOTION_VISIBILITIES,
} from '@pig/core';
import { demandDeals } from './demand';
import { users } from './identity';

/**
 * Render a value set as a SQL `IN` list from the ontology constant.
 *
 * Copied from `learn.ts` rather than imported: it is a private detail of how a
 * schema file renders its constraints, and exporting it would make two files
 * that must be able to diverge share one. The values are compile-time literal
 * constants from `@pig/core`, never input.
 */
const inList = (values: readonly string[]) =>
  sql.raw(values.map((value) => `'${value}'`).join(', '));

export const motionTemplates = pgTable(
  'motion_templates',
  {
    id: uuid('id').primaryKey().defaultRandom(),

    kind: text('kind', { enum: MOTION_KINDS }).notNull(),

    /** Stable across versions: the slug, not the id, is the identity of a lineage. */
    slug: text('slug').notNull(),
    version: integer('version').notNull().default(1),

    title: text('title').notNull(),
    summary: text('summary').notNull(),
    /** Markdown. Rendered by the web app; never framed, never executed. */
    body: text('body').notNull(),
    /** The structured half — scoring dimensions, pricing inputs, checklist items. */
    fields: jsonb('fields').$type<Record<string, unknown>>(),

    stage: text('stage', { enum: DEMAND_STAGES }).notNull(),

    visibility: text('visibility', { enum: MOTION_VISIBILITIES }).notNull().default('private'),
    ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),

    /** The previous version in this lineage. Null on version 1. */
    supersedesId: uuid('supersedes_id').references((): AnyPgColumn => motionTemplates.id, {
      onDelete: 'set null',
    }),
    /**
     * The engagement artifact this version was promoted from. Half of the
     * cycle the migration has to break — see the file header.
     */
    originArtifactId: uuid('origin_artifact_id').references(
      (): AnyPgColumn => engagementArtifacts.id,
      { onDelete: 'set null' },
    ),

    /** The starter library shipped with the product, not somebody's draft. */
    isSystem: boolean('is_system').notNull().default(false),
    /** Incremented on instantiate. Above zero, this row stops being editable. */
    usageCount: integer('usage_count').notNull().default(0),

    archivedAt: timestamp('archived_at', { withTimezone: true }),

    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    check('motion_templates_kind_check', sql`${t.kind} IN (${inList(MOTION_KINDS)})`),
    check('motion_templates_stage_check', sql`${t.stage} IN (${inList(DEMAND_STAGES)})`),
    check(
      'motion_templates_visibility_check',
      sql`${t.visibility} IN (${inList(MOTION_VISIBILITIES)})`,
    ),
    check('motion_templates_version_positive_check', sql`${t.version} > 0`),
    /**
     * Written as an implication so it reads as the rule it encodes: private
     * implies owned. An unowned private row is invisible to every query that
     * is written correctly, which is precisely why it must not exist.
     */
    check(
      'motion_templates_private_has_owner_check',
      sql`${t.visibility} <> 'private' OR ${t.ownerUserId} IS NOT NULL`,
    ),

    /** Load-bearing for the seed's `onConflictDoNothing` — see the file header. */
    unique('motion_templates_slug_version_key').on(t.slug, t.version),

    /** The library browse: a kind, at a stage. */
    index('motion_templates_kind_stage_idx').on(t.kind, t.stage),
    /** The shared-library read, which filters on visibility before anything else. */
    index('motion_templates_visibility_kind_idx').on(t.visibility, t.kind),
    /** "My drafts", the other half of the private/shared split. */
    index('motion_templates_owner_idx').on(t.ownerUserId),
  ],
);

export const engagements = pgTable(
  'engagements',
  {
    id: uuid('id').primaryKey().defaultRandom(),

    /**
     * The spine. An engagement has no independent existence — it is the motion
     * being run against a deal that already exists, so it dies with the deal.
     */
    demandDealId: uuid('demand_deal_id')
      .notNull()
      .references(() => demandDeals.id, { onDelete: 'cascade' }),

    playbookTemplateId: uuid('playbook_template_id').references(() => motionTemplates.id, {
      onDelete: 'set null',
    }),
    ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),

    status: text('status', { enum: ENGAGEMENT_STATUSES }).notNull().default('open'),
    summary: text('summary'),

    openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
    closedAt: timestamp('closed_at', { withTimezone: true }),

    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    check('engagements_status_check', sql`${t.status} IN (${inList(ENGAGEMENT_STATUSES)})`),
    /**
     * One engagement per deal. Two engagements on one deal would give the same
     * opportunity two qualification histories and two answers to "what stage
     * is this at"; if that ever has to relax it relaxes deliberately.
     */
    unique('engagements_demand_deal_key').on(t.demandDealId),
    index('engagements_status_idx').on(t.status),
  ],
);

export const engagementArtifacts = pgTable(
  'engagement_artifacts',
  {
    id: uuid('id').primaryKey().defaultRandom(),

    engagementId: uuid('engagement_id')
      .notNull()
      .references(() => engagements.id, { onDelete: 'cascade' }),

    /** What it was instantiated from. Null for an artifact written from scratch. */
    templateId: uuid('template_id').references(() => motionTemplates.id, { onDelete: 'set null' }),

    kind: text('kind', { enum: MOTION_KINDS }).notNull(),
    stage: text('stage', { enum: DEMAND_STAGES }).notNull(),

    title: text('title').notNull(),
    body: text('body').notNull(),
    fields: jsonb('fields').$type<Record<string, unknown>>(),

    status: text('status', { enum: ARTIFACT_STATUSES }).notNull().default('draft'),
    authoredByUserId: uuid('authored_by_user_id').references(() => users.id, {
      onDelete: 'set null',
    }),

    /** Set once, by promotion. Its presence is what refuses a second promotion. */
    promotedTemplateId: uuid('promoted_template_id').references(() => motionTemplates.id, {
      onDelete: 'set null',
    }),

    archivedAt: timestamp('archived_at', { withTimezone: true }),

    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    check('engagement_artifacts_kind_check', sql`${t.kind} IN (${inList(MOTION_KINDS)})`),
    check('engagement_artifacts_stage_check', sql`${t.stage} IN (${inList(DEMAND_STAGES)})`),
    check('engagement_artifacts_status_check', sql`${t.status} IN (${inList(ARTIFACT_STATUSES)})`),
    /** The workspace reads one engagement's artifacts grouped by stage. */
    index('engagement_artifacts_engagement_stage_idx').on(t.engagementId, t.stage),
  ],
);

export const qualificationScores = pgTable(
  'qualification_scores',
  {
    id: uuid('id').primaryKey().defaultRandom(),

    engagementId: uuid('engagement_id')
      .notNull()
      .references(() => engagements.id, { onDelete: 'cascade' }),
    frameworkTemplateId: uuid('framework_template_id').references(() => motionTemplates.id, {
      onDelete: 'set null',
    }),

    /** `MotionDimensionScore[]` exactly as scored, so an old row survives a reweighted framework. */
    dimensions: jsonb('dimensions').$type<Record<string, unknown>[]>().notNull(),

    /**
     * Basis points of the maximum, integer, computed by
     * `motionScoreBasisPoints`. Stored rather than derived because the
     * framework's weights can change and this number must not.
     */
    basisPoints: integer('basis_points').notNull(),
    band: text('band').notNull(),
    note: text('note'),

    scoredByUserId: uuid('scored_by_user_id').references(() => users.id, { onDelete: 'set null' }),
    scoredAt: timestamp('scored_at', { withTimezone: true }).notNull().defaultNow(),

    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    check(
      'qualification_scores_basis_points_check',
      sql`${t.basisPoints} >= 0 AND ${t.basisPoints} <= ${sql.raw(String(MOTION_BASIS_POINTS_MAX))}`,
    ),
    /** The history panel: one engagement, newest first. */
    index('qualification_scores_engagement_idx').on(t.engagementId, t.scoredAt.desc()),
  ],
);

export type MotionTemplate = typeof motionTemplates.$inferSelect;
export type NewMotionTemplate = typeof motionTemplates.$inferInsert;
export type Engagement = typeof engagements.$inferSelect;
export type NewEngagement = typeof engagements.$inferInsert;
export type EngagementArtifact = typeof engagementArtifacts.$inferSelect;
export type NewEngagementArtifact = typeof engagementArtifacts.$inferInsert;
export type QualificationScore = typeof qualificationScores.$inferSelect;
export type NewQualificationScore = typeof qualificationScores.$inferInsert;
