/**
 * IQ Apparatus render contract — canonical source of truth.
 *
 * ZERO IMPORTS BY DESIGN. This module must evaluate identically under Vite,
 * SSR (workerd), and Deno (Supabase edge functions). Do not add an import
 * statement, do not reference `process`, `Deno`, `window`, or any global.
 *
 * APPEND-ONLY. Never rename, remove, or repurpose an existing field, type, or
 * enum member. Add new ones and bump IQ_CONTRACT_VERSION to a new dated
 * revision. Consumers pin a revision and diff their vendored copy against
 * https://iqapparatus.com/contract/iq-render-contract.ts
 *
 * Mirrored byte-for-byte at supabase/functions/_shared/iq-render-contract.ts
 * (verified by scripts/contract/build-contract-artifacts.mjs --check).
 */

/** Current contract revision. Dated, append-only. */
export const IQ_CONTRACT_VERSION = '2026-09-03';

/** Revisions this API still emits/accepts, oldest first. */
export const IQ_SUPPORTED_CONTRACT_VERSIONS = [
  '2026-08-19',
  '2026-08-20',
  '2026-08-21',
  '2026-08-22',
  '2026-08-23',
  '2026-08-24',
  '2026-08-25',
  '2026-08-26',
  '2026-08-27',
  '2026-08-28',
  '2026-08-31',
  '2026-09-02',
  '2026-09-03',
] as const;


/**
 * Machine-readable `error_code` values on a 4xx delivery-API miss. Added
 * 2026-08-25 so a consumer holding several keys can tell "this activity does
 * not exist" apart from "you used the wrong key for it" without parsing prose.
 *
 * On `course_not_owned_by_key` the envelope also carries
 * `owning_organization_id`: the tenant that actually owns the course. A
 * consumer should retry with the key bound to that organization.
 */
export const IQ_ERROR_CODES = [
  'course_not_found',
  'course_not_published',
  'course_not_owned_by_key',
  'invalid_api_key',
  'insufficient_scope',
  'rate_limited',
] as const;

export type IqErrorCode = (typeof IQ_ERROR_CODES)[number];


/* ------------------------------------------------------------------ *
 * Vocabulary
 * ------------------------------------------------------------------ */

/**
 * Every `component.type` the delivery API can emit, including the snake_case
 * aliases historically produced by imports. Consumers MUST render unknown
 * types as a neutral "new activity element" notice rather than dropping them.
 */
export const COMPONENT_TYPES = [
  // Content
  'content',
  'rich-text',
  'rich_text',
  'html',
  'image',
  'image-hotspot',
  'image_hotspot',
  'audio',
  'video',
  'webinar-embed',
  'webinar_embed',
  'talking-avatar',
  'talking_avatar',
  'ai-video',
  'ai_video',
  // Interaction / reflection
  'reflection',
  'confidence-check',
  'confidence_check',
  'form-input',
  'form_input',
  'button',
  // Assessment & games
  'choice',
  'question',
  'timed-quiz',
  'timed_quiz',
  'drag-drop-match',
  'drag_drop_match',
  'grouping',
  'card-flip',
  'card_flip',
  'narrative-escape',
  'narrative_escape',
  // Evaluation
  'evaluation',
] as const;

export type ComponentType = (typeof COMPONENT_TYPES)[number];

/** Every `phase.type` the delivery API can emit. */
export const PHASE_TYPES = [
  'introduction',
  'pre-assessment',
  'content',
  'education',
  'assessment',
  'post-assessment',
  'evaluation',
  'payment',
  'certificate',
] as const;

export type PhaseType = (typeof PHASE_TYPES)[number];

/**
 * Canonical presentation order. `phases[]` is always returned in the authored
 * order — render it as returned. This list only tells a consumer where an
 * out-of-band phase belongs if it ever needs to sort.
 */
export const PHASE_CANONICAL_ORDER: readonly PhaseType[] = [
  'introduction',
  'pre-assessment',
  'content',
  'education',
  'assessment',
  'post-assessment',
  'evaluation',
  'payment',
  'certificate',
];

/* ------------------------------------------------------------------ *
 * Wire types
 * ------------------------------------------------------------------ */

export interface ApiEnvelope<T> {
  success: boolean;
  data?: T;
  error?: string;
  message?: string;
  contract_version: string;
  /**
   * Organization the authenticated API key is scoped to. Emitted on
   * GET /api-courses, GET /api-course-content and GET /api-whoami.
   * Null only for unscoped platform-wide keys.
   */
  authenticated_organization_id?: string | null;
  pagination?: { page: number; limit: number; total: number; hasMore: boolean };
}

export interface CreditDesignation {
  credit_type: string | null;
  credit_type_slug?: string | null;
  credit_hours: number | null;
  profession: string | null;
  /** 'certificate_settings' (structured) or 'statement' (parsed server-side). */
  source?: 'certificate_settings' | 'statement';
}

export interface AvailableCertificateType {
  profession: string | null;
  credit_type: string | null;
  credit_type_slug: string | null;
  credit_hours: number | null;
}

export interface Accreditation {
  provider_name?: string | null;
  provider_statement?: string | null;
  credit_designation_statement?: string | null;
  credit_designations: CreditDesignation[];
  available_certificate_types?: AvailableCertificateType[];
  release_date?: string | null;
  expiration_date?: string | null;
}

export interface ActivityDetails {
  project_id: string | null;
  credit_hours: number | null;
  moc_points: number | null;
  pharmacology_credit: number | null;
  ilna_points: number | null;
  /** Render verbatim. */
  target_audience: string[];
  primary_category: string | null;
  secondary_category: string | null;
  topic: string | null;
  subspecialty_tags: string[];
}

export interface Reference {
  number?: number | null;
  citation?: string | null;
  doi?: string | null;
  pmid?: string | null;
  url?: string | null;
}

export interface LandingPage {
  statementOfNeed?: string | null;
  targetAudience?: string | null;
  howToEarnCredit?: string | null;
  disclosureSummary?: string | null;
  hardwareRequirements?: string | null;
  [key: string]: unknown;
}

export interface SeriesModuleRef {
  course_id: string;
  title: string;
  status?: string | null;
  position: number | null;
}

export interface Series {
  series_id: string | null;
  name: string;
  description: string | null;
  position: number | null;
  total: number | null;
  modules: SeriesModuleRef[];
  /**
   * Identity of the module this payload is for, repeated inside `series` so a
   * consumer inspecting the series block alone can locate itself in the roster.
   * Same values as the top-level `course_id` / `activity_type` /
   * `delivery_format`. Added in 2026-08-21.
   */
  course_id?: string | null;
  activity_type?: string | null;
  delivery_format?: string | null;
  /** Next/previous module by position; null at the ends. Advisory only. */
  next: SeriesModuleRef | null;
  previous: SeriesModuleRef | null;
  /**
   * ADVISORY, always false. The API never refuses a module because an earlier
   * one is incomplete — it will serve module N with N-1 untouched. Sequential
   * gating is entirely the consumer's to enforce from its own completion
   * records; this flag is not a server-side guarantee and must not be relied on
   * as one.
   */
  sequential_gating_enforced: boolean;
}

export interface MediaBlock {
  video_status: 'ready' | 'pending' | 'failed';
  video_url?: string;
  poster_url?: string;
  caption_url?: string;
  duration_seconds?: number;
  /** ISO 8601. Present whenever `video_url` is a short-lived signed URL. */
  expires_at?: string;
  /** Authored transcript/summary, shipped when the video is not deliverable. */
  fallback_html?: string;
}

export interface Debrief {
  caseTakeaway?: string | null;
  /**
   * States the correct answer for every decision, so it is an answer key and
   * requires the `outcomes:read` scope (contract 2026-09-02). Keys without the
   * scope receive `null` here; correctness comes from api-submit-progress.
   */
  answerKeyHtml?: string | null;
  /**
   * True when the activity authored an answer key, whether or not this key's
   * scopes allow receiving it. Added 2026-09-02.
   */
  answer_key_available?: boolean;
  nextStepsHtml?: string | null;
  [key: string]: unknown;
}

/* ------------------------------------------------------------------ *
 * Decision gating (added 2026-08-28)
 * ------------------------------------------------------------------ */

/**
 * Every delivered component carries these two fields. They declare what was
 * previously only a rendering convention: on a Bramwell decision phase, the
 * blocks authored AFTER the decision (outcome video, teach-back debrief,
 * reflection, confidence-check) state or imply the correct answer, so they
 * MUST stay hidden until the learner has answered the decision and its
 * rationale has been revealed.
 *
 *  - `reveals_on_answer: true` marks a gate anchor — a decision whose answer
 *    unlocks what follows. Anchors themselves are never gated
 *    (`reveal_after: null`), including a second decision in the same phase.
 *  - `reveal_after` holds the `id` of the anchor that must be answered first,
 *    or `null` when the component renders immediately.
 *
 * This is a VISIBILITY rule, not a reorder: render `components` in the order
 * returned and reveal gated blocks in place. `id` is always present on
 * delivered components (synthesized when the authoring record lacked one).
 */
export interface ComponentGating {
  /** Id of the decision that must be answered before this block is shown. */
  reveal_after: string | null;
  /** True on the decision itself: answering it reveals what follows. */
  reveals_on_answer?: boolean;
}

/**
 * Within-phase pagination, declared (added 2026-09-03).
 *
 * A phase is not always one screen. IQ's own player paginates inside a phase,
 * and consumers previously had to guess where the breaks were. Now every
 * delivered component declares the screen it belongs to.
 *
 *  - `screen_index` is 0-based WITHIN the phase.
 *  - `screen_role: 'primary'` is what a Continue button advances from. Exactly
 *    one primary exists per screen and it is the first component of it.
 *  - `screen_role: 'attached'` stays on the same screen as the preceding
 *    primary (scene image + hook prose + confidence slider are one screen).
 *  - `anchor_id` is a stable within-phase anchor for deep links/scroll.
 *
 * Rules IQ guarantees:
 *  - A "Meet the Patient" introduction phase is ALWAYS a single screen
 *    (screen_index 0 for every component in it).
 *  - A content/HTML block with no required interaction is its own primary
 *    screen, so Continue always has a defined next target.
 *  - This is PAGINATION. `reveal_after` / `reveals_on_answer` remain pure
 *    visibility gates and never imply a screen break; a gated block usually
 *    shares the screen of the decision that unlocks it.
 */
export interface ComponentScreenPlacement {
  /** 0-based screen this component renders on, within its phase. */
  screen_index: number;
  /** 'primary' starts a screen; 'attached' joins the preceding primary's screen. */
  screen_role: ScreenRole;
  /** Stable anchor id for deep-linking/scrolling within the phase. */
  anchor_id?: string;
}

export const SCREEN_ROLES = ['primary', 'attached'] as const;
export type ScreenRole = (typeof SCREEN_ROLES)[number];

/** How a phase presents its screens. Mirrors IQ's authored componentDisplayMode. */
export const PHASE_DISPLAY_MODES = ['sequential', 'stacked'] as const;
export type PhaseDisplayMode = (typeof PHASE_DISPLAY_MODES)[number];

export type DeliveredComponent = ComponentGating &
  ComponentScreenPlacement & {
    id: string;
    type: ComponentType | string;
    [key: string]: unknown;
  };

export interface Phase {
  id: string;
  title: string;
  type: PhaseType | string;
  required: boolean;
  order: number;
  components: DeliveredComponent[];
  /**
   * 'sequential' = one screen at a time with Continue between screens (IQ
   * default). 'stacked' = the whole phase scrolls as one screen; treat
   * screen_index as grouping only. Added 2026-09-03.
   */
  display_mode: PhaseDisplayMode;
  /** Number of distinct screens in this phase (max screen_index + 1). Added 2026-09-03. */
  screen_count: number;
  passingScore?: number;
  debrief_readout?: unknown;
}


/* ------------------------------------------------------------------ *
 * Grading split for `choice` components (added 2026-08-27)
 * ------------------------------------------------------------------ */

/**
 * A `choice` component has two contractual grading modes. Read
 * `client_graded` — never infer from the presence of a key.
 *
 *  - `assessmentMode: true` → `client_graded: false`. Scored item. The key,
 *    the component rationale and every `options[].isCorrect` / rationale are
 *    stripped from `GET /api-course-content`. Submit the answer to
 *    `POST /api-submit-progress` and render the returned verdict.
 *
 *  - `assessmentMode` absent/false → `client_graded: true` when a key exists.
 *    Formative clinical decision. `correctAnswerIndex` is delivered, always
 *    normalized to a 0-based index into `options`, together with `rationale`.
 *    Reveal locally on selection; there is no server round trip for these and
 *    the grader will report them as `graded: false` if you submit them.
 *
 *  A poll-style choice with no authored key is `client_graded: false` with no
 *  `correctAnswerIndex` — render it as an opinion poll, never as a decision.
 */
export interface ChoiceComponentDelivery {
  type: 'choice';
  id: string;
  question: string;
  options: Array<{ value: string; label: string; rationale?: string | null }>;
  assessmentMode?: boolean;
  /** True only when the authored key ships with the component. */
  client_graded: boolean;
  /** 0-based index into `options`. Present only when `client_graded` is true. */
  correctAnswerIndex?: number;
  /** Reveal copy for the decision. Present only when `client_graded` is true. */
  rationale?: string | null;
}

/**
 * One row of `question_results` on a `POST /api-submit-progress` response.
 *
 * Since 2026-08-27 every submitted answer produces a row, and `graded` states
 * plainly whether a verdict was possible. Never block a reveal waiting for
 * `is_correct` on a row where `graded` is false.
 */
export interface QuestionResult {
  question_id: string;
  selected_answer: string;
  /** False when the item has no authored key, or the id is not in the phase. */
  graded: boolean;
  /** Canonical option value. Null when `graded` is false. */
  correct_answer: string | null;
  /** Null when `graded` is false. */
  is_correct: boolean | null;
  rationale: string | null;
  /** Only on ungraded rows. */
  ungraded_reason?: 'no_authored_answer_key' | 'question_id_not_found_in_phase';
}


/* ------------------------------------------------------------------ *
 * Catalog card presentation (added 2026-08-20)
 * ------------------------------------------------------------------ */

/** One credit chip. Chips with the same amount are merged into one chip. */
export interface CreditChip {
  amount: number;
  /** Accreditation acronyms, e.g. ["CME", "NCPD"]. Generic "CE" is suppressed. */
  labels: string[];
  /** Ready-to-render string, e.g. "0.25 CME · NCPD". */
  display: string;
}

export interface CardPrice {
  amount: number;
  is_free: boolean;
  /** "Free", or USD with cents only when the amount is not whole. */
  display: string;
}

/** Guest-state call to action. Enrollment-aware labels are host-side. */
export interface CardCta {
  label: string;
  style: 'primary' | 'outline';
}

/** Series membership of a catalog item. */
export interface CatalogSeriesRef {
  id: string;
  name: string | null;
  /** 1-based order within the series when the author set one. */
  position: number | null;
  /** Full published roster size for the series, not the current page. */
  total: number | null;
  is_series_module: true;
}

/** Everything a consumer needs to render a catalog card with IQ parity. */
export interface CardDisplayConfig {
  image: { url: string | null; alt: string; aspect_ratio: string };
  badge: { style: string; text: string | null } | null;
  stripe: { text: string; variant: string } | null;
  is_featured: boolean;
  credit_chips: CreditChip[];
  hide_credit_chips: boolean;
  price: CardPrice;
  provider_name: string | null;
  /** Short format/feature line, e.g. "15-minute case decision". */
  format_line: string | null;
  cta: CardCta;
  /** Plain-text summary (HTML stripped) for the card body. */
  summary: string | null;
}

/** One item of GET /api-courses (list entry or single course). */
export interface CatalogItem {
  contract_version: string;
  id: string;
  /**
   * UUID of the IQ organization that owns and publishes this activity.
   * Non-null on every catalog item, including unlisted ones. Stable across
   * revisions of the same course, and identical to the organization an API
   * key is scoped to. Use it to enforce provider scoping by data.
   */
  organization_id: string;
  name: string;
  description: string | null;
  status: string;
  activity_type: string;
  delivery_format: string | null;
  disease_state: string | null;
  therapeutic_area: string | null;
  marketplace_price: number | null;
  included_in_premium: boolean | null;
  launch_date: string | null;
  expiration_date: string | null;
  thumbnail_url: string | null;
  is_featured: boolean | null;
  created_at: string;
  updated_at: string;
  card_display_config: CardDisplayConfig;
  credit_designations: CreditDesignation[];
  available_certificate_types: AvailableCertificateType[];
  accredited_provider_name: string | null;
  activity_details: ActivityDetails;
  /** Non-null when the activity is one module of a microlearning series. */
  series: CatalogSeriesRef | null;
  /**
   * Landing-page overview block. Added 2026-08-24 so a consumer can render
   * "About this activity" from the catalog fetch alone, without a second call
   * to GET /api-course-content. Non-gated fields only — identical values to
   * the corresponding fields on CourseContentPayload.
   */
  overview: ActivityOverview;
}

/** Public faculty entry. Names/credentials only; contact details never ship. */
export interface OverviewFacultyMember {
  name: string;
  degree: string | null;
  title_affiliation: string | null;
}

/**
 * Pre-enrollment overview of an activity, emitted on every catalog item.
 * Every member is drawn from NON_GATED_OVERVIEW_FIELDS.
 */
export interface ActivityOverview {
  /** "About this activity" body (HTML, verbatim). */
  about: string | null;
  statement_of_need: string | null;
  learning_objectives: string[];
  target_audience: string[];
  accreditation: Accreditation | null;
  how_to_earn_credit: string | null;
  estimated_time: string | null;
  release_date: string | null;
  expiration_date: string | null;
  commercial_support: string | null;
  disclosure_summary: string | null;
  hardware_software_requirements: string | null;
  faculty: OverviewFacultyMember[];
  references: Reference[];
}

/**
 * Series-level card composed by the CONSUMER from catalog items sharing a
 * `series.id`. IQ does not emit this object — it defines the shape so a
 * consumer rail matches the IQ series card exactly.
 */
export interface SeriesCardView {
  series_id: string;
  series_name: string;
  /** Modules the consumer can open today, ordered by `series.position`. */
  modules: CatalogItem[];
  /** Full roster size (max of `series.total` and `modules.length`). */
  total_modules: number;
  /** total_modules - modules.length. Rendered as "N coming soon". */
  locked_modules: number;
  total_credit_hours: number;
  total_minutes: number;
  /** 0 when every module is free; otherwise the lowest paid module price. */
  min_price: number;
  has_any_price: boolean;
}


/* ------------------------------------------------------------------ *
 * Outcomes follow-up instrument (added 2026-08-22)
 * ------------------------------------------------------------------ */

/** Copy for the post-completion check-in email. Render verbatim. */
export interface FollowUpEmail {
  subject: string;
  intro: string;
  cta_label: string;
}

/** Copy for the tokenized, session-free landing page. Render verbatim. */
export interface FollowUpLanding {
  title: string;
  intro: string;
  /**
   * The accrediting provider's statement for this activity, verbatim.
   * MUST appear unmodified in the email and on the landing page.
   * `null` means the activity has no statement to reproduce — do not invent one.
   */
  accreditation_note: string | null;
}

/** One selectable commitment-to-change option. `id` is stable and reported. */
export interface FollowUpOption {
  id: string;
  label: string;
}

export interface FollowUpCommitment {
  prompt: string;
  options: FollowUpOption[];
  allow_free_text: boolean;
}

/** Survey item types a consumer must support. Unknown types are dropped. */
export const FOLLOW_UP_ITEM_TYPES = ['single', 'multi', 'scale', 'text'] as const;
export type FollowUpItemType = (typeof FOLLOW_UP_ITEM_TYPES)[number];

export interface FollowUpSurveyItem {
  /** Stable across revisions. Stored and reported by the consumer. */
  id: string;
  prompt: string;
  type: FollowUpItemType;
  /** Present for single / multi. */
  options?: FollowUpOption[];
  required?: boolean;
  /** Present for scale. */
  min?: number;
  max?: number;
  min_label?: string;
  max_label?: string;
}

export interface FollowUpSurvey {
  /**
   * Item id `practice_changed` with option ids yes / partially / no is the
   * headline outcomes metric when present.
   */
  items: FollowUpSurveyItem[];
  barriers: FollowUpOption[];
}

/** Optional knowledge re-check. See the answer-key policy below. */
export interface FollowUpRecheckOption extends FollowUpOption {
  /**
   * ONLY emitted for API keys holding the `outcomes:read` scope. Keys without
   * that scope receive the same options with every `is_correct` omitted, so
   * no answer key can reach a browser. Grade server-side.
   */
  is_correct?: boolean;
}

export interface FollowUpRecheck {
  id: string;
  stem: string;
  options: FollowUpRecheckOption[];
  /** Shown after the learner answers. */
  rationale: string | null;
}

/**
 * Accredited outcomes-measurement instrument for this activity.
 *
 * Additive since 2026-08-22 and OPTIONAL: activities without an authored
 * instrument omit the field entirely and behave exactly as before. A block
 * with no commitment options, no survey items, and no recheck is treated as
 * absent and is not emitted.
 *
 * IQ Apparatus is author of record for this wording; the consumer is sender of
 * record (its own brand and sending domain). Render the copy verbatim.
 */
export interface FollowUp {
  /** Positive integers, days after completion. Defaults to [30, 60, 90]. */
  intervals_days: number[];
  email: FollowUpEmail;
  landing: FollowUpLanding;
  commitment: FollowUpCommitment;
  survey: FollowUpSurvey;
  recheck?: FollowUpRecheck | null;
}

/** GET /api-whoami payload. Lets a key discover its own provider binding. */
export interface WhoAmIPayload {
  /** Organization this key is scoped to; null for unscoped platform-wide keys. */
  organization_id: string | null;
  /** Organization display name; null when unscoped. */
  name: string | null;
  /** Scopes granted to this key, e.g. ['courses:read', 'outcomes:read']. */
  scopes: string[];
  /**
   * Operator-chosen label of the key itself. Added 2026-08-25 for diagnostics
   * only — labels are free text and may not match the bound tenant. Always
   * route on `organization_id`, never on `key_name`.
   */
  key_name?: string | null;
}


/** Scope required to receive `follow_up.recheck[].options[].is_correct`. */
export const OUTCOMES_SCOPE = 'outcomes:read' as const;

/** GET /api-course-content payload (gated: requires enrollment on the host). */
export interface CourseContentPayload {
  contract_version: string;
  course_id: string;
  /** Owning IQ organization. Non-null. Same value as the catalog item's. */
  organization_id: string;
  name: string;
  description: string | null;
  activity_type: string | null;
  delivery_format: string | null;
  /** "bramwell" → render with the Bramwell spec; null → standard CE. */
  format: string | null;
  delivery_style: string | null;
  total_phases: number;
  phases: Phase[];
  learning_objectives: string[];
  accreditation: Accreditation | null;
  /**
   * Authoritative structured credit data. Never parse the prose accreditation
   * statement. Emitted since 2026-08-19; declared here since 2026-08-21.
   */
  credit_designations: CreditDesignation[];
  available_certificate_types: AvailableCertificateType[];
  activity_details: ActivityDetails;
  landing_page: LandingPage | null;
  evaluation_questions: unknown[];
  confidence_baseline: unknown | null;
  debrief: Debrief | null;
  references: Reference[];
  series: Series | null;
  /**
   * Accredited outcomes-measurement instrument. Added 2026-08-22. Omitted
   * entirely when the activity has no published instrument.
   */
  follow_up?: FollowUp | null;
  /** ISO 8601. Present whenever the payload contains any signed media URL. */
  media_expires_at?: string;
}

/* ------------------------------------------------------------------ *
 * Field policy
 * ------------------------------------------------------------------ */

/** Fields a consumer must forward untouched (no re-derivation, no reformatting). */
export const PASSTHROUGH_FIELDS = [
  'contract_version',
  'accreditation',
  'credit_designations',
  'available_certificate_types',
  'learning_objectives',
  'activity_details',
  'landing_page',
  'evaluation_questions',
  'confidence_baseline',
  'debrief',
  'references',
  'series',
  'follow_up',
] as const;

/**
 * The ONLY fields that may be exposed before enrollment. Anything not on this
 * list (phases, components, evaluation_questions, confidence_baseline,
 * debrief, answer keys) stays behind the enrollment gate.
 */
export const NON_GATED_OVERVIEW_FIELDS = [
  'contract_version',
  'format',
  'delivery_style',
  'landing_page',
  'learning_objectives',
  'accreditation',
  'activity_details',
  'references',
  'series',
] as const;

/** Signed-media policy, stated so consumers do not have to infer it. */
export const MEDIA_POLICY = {
  /** Signed URL lifetime in seconds (6 hours). */
  ttl_seconds: 60 * 60 * 6,
  /** Re-sign by refetching course content; there is no separate re-sign endpoint. */
  refresh_endpoint: 'GET /api-course-content?course_id={course_id}',
  refresh_note:
    'Refetching the course re-signs every asset in the payload. Do not cache a video_url past its expires_at (or the payload-level media_expires_at).',
} as const;

/**
 * Decision-gating policy, declared so consumers never have to infer it from
 * prose. Added 2026-08-28.
 */
export const GATING_POLICY = {
  anchor_types: ['choice', 'question', 'timed-quiz'],
  anchor_field: 'reveals_on_answer',
  gate_field: 'reveal_after',
  rule:
    'Render components in the returned order. A component whose reveal_after is non-null MUST stay hidden until the referenced component has been answered and its rationale revealed (locally for client_graded decisions, or from POST /api-submit-progress for assessmentMode items). Revealing does not reorder: the block appears in its authored position.',
  rationale:
    'On a Bramwell decision phase the outcome video and teach-back debrief state the correct answer. Rendering them before the learner answers spoils the decision.',
} as const;

/**
 * `confidence-check` is a slider, always. It carries statement/prompt, min,
 * max, minLabel, maxLabel and never an `options` array. A payload containing
 * `options` on a confidence-check is malformed — fall back to a radio scale
 * over the same integer values.
 */
export const CONFIDENCE_CHECK_CONTROL = {
  control: 'slider',
  fields: ['statement', 'min', 'max', 'minLabel', 'maxLabel', 'helperText'],
  never: ['options'],
  degraded_mode: 'Radio scale over the same integer values from min to max.',
} as const;

/**
 * Image delivery policy. Added 2026-08-31.
 *
 * Every image URL in a payload is absolute and publicly fetchable without
 * authentication — a consumer never has to resolve a path against an IQ
 * origin. This covers `image` components, catalog `card_display_config.image`,
 * and the Bramwell hook scene illustration.
 *
 * The Bramwell hook scene ("Meet the patient") used to be an IQ-side render of
 * an activity-level field, invisible in the payload. It is now DECLARED: when
 * present it ships as the first component of the first `introduction` phase,
 * typed `image`, carrying `source: "bramwell_patient_image"`. Consumers render
 * it like any other image component — do not special-case it, and do not infer
 * a scene image when the field is absent.
 */
export const IMAGE_POLICY = {
  urls_absolute: true,
  auth_required: false,
  component_fields: ['url', 'alt', 'caption', 'source', 'alt_is_fallback'],
  scene_image: {
    component_type: 'image',
    source: 'bramwell_patient_image',
    position: 'first component of the first introduction phase',
    injected_when: 'the activity has a scene illustration and the phase has no authored inline image component',
    aspect_ratio: '16/9',
  },
  alt_text:
    'alt is always a non-empty string. When alt_is_fallback is true the activity carried no authored alt and IQ supplied a generic description — a consumer may substitute its own.',
} as const;


/**
 * Within-phase pagination policy. Added 2026-09-03.
 */
export const SCREEN_POLICY = {
  roles: SCREEN_ROLES,
  display_modes: PHASE_DISPLAY_MODES,
  component_fields: ['screen_index', 'screen_role', 'anchor_id'],
  phase_fields: ['display_mode', 'screen_count'],
  rule:
    'Group a phase\'s components by screen_index and render one group at a time when display_mode is "sequential". Continue advances to the next screen_index; on the last screen it advances to the next phase. Never derive screen breaks from reveal_after/reveals_on_answer — those are visibility gates within a screen.',
  guarantees: [
    'A "Meet the Patient" introduction phase is one screen: scene image + hook prose + confidence slider share screen_index 0.',
    'A content/HTML block with no required interaction is its own primary screen, so Continue always has a defined next target.',
    'Interactive blocks authored directly beneath a text block (choice, form-input) are attached to that text block\'s screen.',
  ],
} as const;

/** Machine-readable summary served at /contract/iq-render-contract.schema.json */
export const IQ_CONTRACT_SCHEMA = {
  $schema: 'https://json-schema.org/draft/2020-12/schema',
  $id: 'https://iqapparatus.com/contract/iq-render-contract.schema.json',
  title: 'IQ Apparatus render contract',
  version: IQ_CONTRACT_VERSION,
  supported_versions: IQ_SUPPORTED_CONTRACT_VERSIONS,
  component_types: COMPONENT_TYPES,
  phase_types: PHASE_TYPES,
  phase_canonical_order: PHASE_CANONICAL_ORDER,
  passthrough_fields: PASSTHROUGH_FIELDS,
  non_gated_overview_fields: NON_GATED_OVERVIEW_FIELDS,
  error_codes: IQ_ERROR_CODES,
  media_policy: MEDIA_POLICY,
  gating_policy: GATING_POLICY,
  confidence_check_control: CONFIDENCE_CHECK_CONTROL,
  image_policy: IMAGE_POLICY,
  screen_policy: SCREEN_POLICY,

  definitions: {
    ApiEnvelope: {
      type: 'object',
      required: ['success', 'contract_version'],
      properties: {
        success: { type: 'boolean' },
        contract_version: { type: 'string' },
        authenticated_organization_id: {
          type: ['string', 'null'],
          format: 'uuid',
          description:
            "The organization the authenticated API key is scoped to. Null only for unscoped platform-wide keys. Emitted on GET /api-courses, GET /api-course-content and GET /api-whoami.",
        },
        data: {},
        error: { type: 'string' },
        error_code: {
          enum: IQ_ERROR_CODES,
          description:
            'Machine-readable failure reason on a 4xx. Added 2026-08-25. Route on this, not on the prose in `error`.',
        },
        owning_organization_id: {
          type: ['string', 'null'],
          format: 'uuid',
          description:
            'Present when error_code is course_not_owned_by_key: the organization that owns the requested course. Retry with the key bound to it.',
        },
        message: { type: 'string' },

        pagination: {
          type: 'object',
          properties: {
            page: { type: 'integer' },
            limit: { type: 'integer' },
            total: { type: 'integer' },
            hasMore: { type: 'boolean' },
          },
        },
      },
    },
    CreditDesignation: {
      type: 'object',
      required: ['credit_type', 'credit_hours', 'profession'],
      properties: {
        credit_type: { type: ['string', 'null'] },
        credit_type_slug: { type: ['string', 'null'] },
        credit_hours: { type: ['number', 'null'] },
        profession: { type: ['string', 'null'] },
        source: { enum: ['certificate_settings', 'statement'] },
      },
    },
    CatalogItem: {
      type: 'object',
      required: [
        'contract_version',
        'id',
        'organization_id',
        'name',
        'status',
        'activity_type',
        'credit_designations',
        'available_certificate_types',
        'accredited_provider_name',
        'activity_details',
        'card_display_config',
        'overview',
      ],
      properties: {
        contract_version: { type: 'string' },
        overview: {
          type: 'object',
          required: ['about', 'learning_objectives', 'references', 'faculty'],
          properties: {
            about: { type: ['string', 'null'] },
            statement_of_need: { type: ['string', 'null'] },
            learning_objectives: { type: 'array', items: { type: 'string' } },
            target_audience: { type: 'array', items: { type: 'string' } },
            accreditation: { type: ['object', 'null'] },
            how_to_earn_credit: { type: ['string', 'null'] },
            estimated_time: { type: ['string', 'null'] },
            release_date: { type: ['string', 'null'] },
            expiration_date: { type: ['string', 'null'] },
            commercial_support: { type: ['string', 'null'] },
            disclosure_summary: { type: ['string', 'null'] },
            hardware_software_requirements: { type: ['string', 'null'] },
            faculty: {
              type: 'array',
              items: {
                type: 'object',
                required: ['name'],
                properties: {
                  name: { type: 'string' },
                  degree: { type: ['string', 'null'] },
                  title_affiliation: { type: ['string', 'null'] },
                },
              },
            },
            references: { type: 'array' },
          },
        },
        id: { type: 'string', format: 'uuid' },
        organization_id: { type: 'string', format: 'uuid' },
        name: { type: 'string' },
        description: { type: ['string', 'null'] },
        status: { type: 'string' },
        activity_type: { type: 'string' },
        accredited_provider_name: { type: ['string', 'null'] },
        credit_designations: { type: 'array', items: { $ref: '#/definitions/CreditDesignation' } },
        available_certificate_types: { type: 'array' },
        activity_details: { type: 'object' },
        card_display_config: {
          type: 'object',
          required: ['image', 'credit_chips', 'price', 'cta'],
          properties: {
            image: {
              type: 'object',
              properties: {
                url: { type: ['string', 'null'] },
                alt: { type: 'string' },
                aspect_ratio: { type: 'string' },
              },
            },
            badge: { type: ['object', 'null'] },
            stripe: { type: ['object', 'null'] },
            is_featured: { type: 'boolean' },
            credit_chips: {
              type: 'array',
              items: {
                type: 'object',
                required: ['amount', 'labels', 'display'],
                properties: {
                  amount: { type: 'number' },
                  labels: { type: 'array', items: { type: 'string' } },
                  display: { type: 'string' },
                },
              },
            },
            hide_credit_chips: { type: 'boolean' },
            price: {
              type: 'object',
              required: ['amount', 'is_free', 'display'],
              properties: {
                amount: { type: 'number' },
                is_free: { type: 'boolean' },
                display: { type: 'string' },
              },
            },
            provider_name: { type: ['string', 'null'] },
            format_line: { type: ['string', 'null'] },
            cta: {
              type: 'object',
              required: ['label', 'style'],
              properties: {
                label: { type: 'string' },
                style: { enum: ['primary', 'outline'] },
              },
            },
            summary: { type: ['string', 'null'] },
          },
        },
        series: {
          type: ['object', 'null'],
          properties: {
            id: { type: 'string' },
            name: { type: ['string', 'null'] },
            position: { type: ['number', 'null'] },
            total: { type: ['number', 'null'] },
            is_series_module: { type: 'boolean' },
          },
        },
      },
    },
    CourseContentPayload: {
      type: 'object',
      required: [
        'contract_version',
        'course_id',
        'organization_id',
        'name',
        'format',
        'total_phases',
        'phases',
        'learning_objectives',
        'accreditation',
        'available_certificate_types',
        'activity_details',
        'references',
        'series',
      ],
      properties: {
        contract_version: { type: 'string' },
        course_id: { type: 'string', format: 'uuid' },
        organization_id: { type: 'string', format: 'uuid' },
        format: { type: ['string', 'null'] },
        delivery_style: { type: ['string', 'null'] },
        total_phases: { type: 'integer' },
        phases: {
          type: 'array',
          items: {
            type: 'object',
            required: [
              'id',
              'title',
              'type',
              'required',
              'order',
              'components',
              'display_mode',
              'screen_count',
            ],
            properties: {
              id: { type: 'string' },
              title: { type: 'string' },
              type: { type: 'string' },
              required: { type: 'boolean' },
              order: { type: 'integer' },
              display_mode: {
                enum: PHASE_DISPLAY_MODES,
                description:
                  'Added 2026-09-03. "sequential" = one screen at a time with Continue; "stacked" = the phase scrolls as one screen.',
              },
              screen_count: {
                type: 'integer',
                minimum: 1,
                description: 'Added 2026-09-03. Number of distinct screen_index values in this phase.',
              },
              components: {
                type: 'array',
                items: {
                  type: 'object',
                  required: ['id', 'type', 'reveal_after', 'screen_index', 'screen_role'],
                  properties: {
                    id: { type: 'string' },
                    type: { type: 'string' },
                    reveal_after: { type: ['string', 'null'] },
                    reveals_on_answer: { type: 'boolean' },
                    screen_index: {
                      type: 'integer',
                      minimum: 0,
                      description: 'Added 2026-09-03. 0-based screen within the phase.',
                    },
                    screen_role: {
                      enum: SCREEN_ROLES,
                      description:
                        'Added 2026-09-03. "primary" starts a screen and is what Continue advances from; "attached" shares the preceding primary\'s screen.',
                    },
                    anchor_id: { type: 'string' },
                  },
                },
              },
            },
          },
        },
        learning_objectives: { type: 'array', items: { type: 'string' } },
        accreditation: { type: ['object', 'null'] },
        series: { type: ['object', 'null'] },
        follow_up: { $ref: '#/definitions/FollowUp' },
        media_expires_at: { type: 'string', format: 'date-time' },
      },
    },
    FollowUp: {
      type: ['object', 'null'],
      required: ['intervals_days', 'email', 'landing', 'commitment', 'survey'],
      properties: {
        intervals_days: { type: 'array', items: { type: 'integer', minimum: 1 } },
        email: {
          type: 'object',
          required: ['subject', 'intro', 'cta_label'],
          properties: {
            subject: { type: 'string' },
            intro: { type: 'string' },
            cta_label: { type: 'string' },
          },
        },
        landing: {
          type: 'object',
          required: ['title', 'intro', 'accreditation_note'],
          properties: {
            title: { type: 'string' },
            intro: { type: 'string' },
            accreditation_note: { type: ['string', 'null'] },
          },
        },
        commitment: {
          type: 'object',
          required: ['prompt', 'options', 'allow_free_text'],
          properties: {
            prompt: { type: 'string' },
            options: { type: 'array', items: { $ref: '#/definitions/FollowUpOption' } },
            allow_free_text: { type: 'boolean' },
          },
        },
        survey: {
          type: 'object',
          required: ['items', 'barriers'],
          properties: {
            items: {
              type: 'array',
              items: {
                type: 'object',
                required: ['id', 'prompt', 'type'],
                properties: {
                  id: { type: 'string' },
                  prompt: { type: 'string' },
                  type: { enum: FOLLOW_UP_ITEM_TYPES },
                  options: { type: 'array', items: { $ref: '#/definitions/FollowUpOption' } },
                  required: { type: 'boolean' },
                  min: { type: 'number' },
                  max: { type: 'number' },
                  min_label: { type: 'string' },
                  max_label: { type: 'string' },
                },
              },
            },
            barriers: { type: 'array', items: { $ref: '#/definitions/FollowUpOption' } },
          },
        },
        recheck: {
          type: ['object', 'null'],
          required: ['id', 'stem', 'options'],
          properties: {
            id: { type: 'string' },
            stem: { type: 'string' },
            options: {
              type: 'array',
              items: {
                type: 'object',
                required: ['id', 'label'],
                properties: {
                  id: { type: 'string' },
                  label: { type: 'string' },
                  is_correct: {
                    type: 'boolean',
                    description: 'Only emitted for API keys holding the outcomes:read scope.',
                  },
                },
              },
            },
            rationale: { type: ['string', 'null'] },
          },
        },
      },
    },
    WhoAmI: {
      type: 'object',
      required: ['organization_id', 'name', 'scopes'],
      properties: {
        organization_id: { type: ['string', 'null'], format: 'uuid' },
        name: { type: ['string', 'null'] },
        scopes: { type: 'array', items: { type: 'string' } },
      },
    },
    FollowUpOption: {
      type: 'object',
      required: ['id', 'label'],
      properties: {
        id: { type: 'string' },
        label: { type: 'string' },
      },
    },
  },
} as const;
