-- Migration 029 — Playout / Master Control (MCR).
--
-- Adds a broadcast playout subsystem: take library assets, arrange them on a
-- playlist (Phase A) or a wall-clock timeline (Phase B), and play them out
-- continuously to SDI (DeckLink) / NDI / SRT / RTMP via a CasparCG sidecar.
--
-- This is the mirror of the capture path (input -> ffmpeg -> S3). A channel is
-- placed on a cluster node by capability the same way recorders claim input
-- ports; the engine container is spawned via the same Docker-socket /
-- node-agent orchestration. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md.
--
-- Tables:
--   playout_channels  — a logical output (one channel -> one CasparCG instance -> one target)
--   playout_playlists — an ordered list of items bound to a channel (Phase A)
--   playout_items     — one clip on a playlist OR one row on the timeline
--   playout_sidecars  — running CasparCG sidecar registry (one per channel; health-checked)
--   playout_schedule  — wall-clock day-ahead rows (Phase B; unused in A)
--   playout_as_run    — append-only log of what actually played (compliance)

-- ── Channels ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS playout_channels (
  id            UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  name          TEXT        NOT NULL,
  node_id       UUID        REFERENCES cluster_nodes(id) ON DELETE SET NULL,
  output_type   TEXT        NOT NULL DEFAULT 'srt',
  -- output_config is consumer-shape-specific:
  --   decklink: { "device_index": 1 }
  --   ndi:      { "ndi_name": "DRAGONFLIGHT CH1" }
  --   srt:      { "url": "srt://host:9000", "latency": 200 }
  --   rtmp:     { "url": "rtmp://host/live", "key": "streamkey" }
  output_config JSONB       NOT NULL DEFAULT '{}'::jsonb,
  -- 1080p59.94 is the house standard (matches capture cadence, streaming-friendly,
  -- accepted by current SDI gear). Per-channel override allowed.
  video_format  TEXT        NOT NULL DEFAULT '1080p5994',
  status        TEXT        NOT NULL DEFAULT 'stopped',
  container_id  TEXT,
  -- For remote channels the node-agent reports the reachable host:port of the
  -- sidecar HTTP shim; stored here so the API can proxy transport calls.
  container_meta JSONB      NOT NULL DEFAULT '{}'::jsonb,
  error_message TEXT,
  -- Failover bookkeeping. Scheduler tick health-checks the sidecar; on N missed
  -- checks the channel is re-placed on a healthy node (auto for ndi/srt/rtmp,
  -- alert-only for decklink — device-index pinning makes re-placement non-trivial).
  restart_count       INTEGER     NOT NULL DEFAULT 0,
  last_restart_at     TIMESTAMPTZ,
  last_heartbeat_at   TIMESTAMPTZ,
  -- RBAC scoping: a NULL project_id resolves to admin-only (authz.js), the same
  -- convention recorders use for unassigned resources.
  project_id    UUID        REFERENCES projects(id) ON DELETE SET NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CHECK (output_type IN ('decklink','ndi','srt','rtmp')),
  CHECK (status IN ('stopped','starting','running','error'))
);

CREATE INDEX IF NOT EXISTS idx_playout_channels_node    ON playout_channels (node_id);
CREATE INDEX IF NOT EXISTS idx_playout_channels_project ON playout_channels (project_id);

-- ── Playlists ──────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS playout_playlists (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  channel_id  UUID        NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
  name        TEXT        NOT NULL,
  loop        BOOLEAN     NOT NULL DEFAULT FALSE,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_playout_playlists_channel ON playout_playlists (channel_id);

-- ── Items ──────────────────────────────────────────────────────────────────
-- One entry on a playlist (Phase A, ordered by sort_order) OR one entry on the
-- timeline (Phase B, ordered by scheduled_at). in/out points reuse the editor's
-- subclip trim model (seconds). media_status tracks the S3 -> /media staging
-- (see playout-stage worker job); a clip cannot go on air until 'ready'.
CREATE TABLE IF NOT EXISTS playout_items (
  id            UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  playlist_id   UUID        REFERENCES playout_playlists(id) ON DELETE CASCADE,
  asset_id      UUID        NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
  sort_order    INTEGER     NOT NULL DEFAULT 0,
  scheduled_at  TIMESTAMPTZ,
  in_point      NUMERIC,
  out_point     NUMERIC,
  transition    TEXT        NOT NULL DEFAULT 'cut',
  transition_ms INTEGER     NOT NULL DEFAULT 0,
  graphics      JSONB,
  media_status  TEXT        NOT NULL DEFAULT 'pending',
  media_path    TEXT,
  -- Set when playout-stage has run loudnorm (EBU R128, -23 LUFS / -1 dBTP) on
  -- the staged file. Re-stages skip the loudnorm pass when true.
  audio_normalized BOOLEAN  NOT NULL DEFAULT FALSE,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CHECK (transition IN ('cut','mix','wipe')),
  CHECK (media_status IN ('pending','staging','ready','error'))
);

CREATE INDEX IF NOT EXISTS idx_playout_items_playlist ON playout_items (playlist_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_playout_items_asset    ON playout_items (asset_id);

-- ── Sidecars ───────────────────────────────────────────────────────────────
-- Running CasparCG container registry, one row per running channel. The
-- scheduler tick (src/scheduler.js) pings each sidecar's /status endpoint and
-- updates last_heartbeat_at; missed checks trigger the failover path in
-- routes/playout.js.
CREATE TABLE IF NOT EXISTS playout_sidecars (
  id                UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  channel_id        UUID        NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
  node_id           UUID        REFERENCES cluster_nodes(id) ON DELETE SET NULL,
  container_id      TEXT        NOT NULL,
  sidecar_url       TEXT,                                       -- http://host:port for the shim
  amcp_port         INTEGER,                                    -- in-container AMCP port (default 5250)
  status            TEXT        NOT NULL DEFAULT 'running',
  last_heartbeat_at TIMESTAMPTZ,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CHECK (status IN ('starting','running','error','stopped'))
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_playout_sidecars_channel ON playout_sidecars (channel_id)
  WHERE status IN ('starting','running');
CREATE INDEX IF NOT EXISTS idx_playout_sidecars_status ON playout_sidecars (status);

-- ── Schedule (Phase B) ─────────────────────────────────────────────────────
-- Wall-clock day-ahead timeline. The scheduler tick (src/scheduler.js, under
-- the existing PG advisory lock) drives transitions and gap-fill. Unused by the
-- Phase A playlist player but created now so the schema is stable.
CREATE TABLE IF NOT EXISTS playout_schedule (
  id            UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  channel_id    UUID        NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
  asset_id      UUID        REFERENCES assets(id) ON DELETE SET NULL,
  scheduled_at  TIMESTAMPTZ NOT NULL,
  in_point      NUMERIC,
  out_point     NUMERIC,
  transition    TEXT        NOT NULL DEFAULT 'cut',
  transition_ms INTEGER     NOT NULL DEFAULT 0,
  is_filler     BOOLEAN     NOT NULL DEFAULT FALSE,
  status        TEXT        NOT NULL DEFAULT 'scheduled',
  media_status  TEXT        NOT NULL DEFAULT 'pending',
  media_path    TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CHECK (transition IN ('cut','mix','wipe')),
  CHECK (status IN ('scheduled','playing','played','skipped','error')),
  CHECK (media_status IN ('pending','staging','ready','error'))
);

CREATE INDEX IF NOT EXISTS idx_playout_schedule_channel_time ON playout_schedule (channel_id, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_playout_schedule_status       ON playout_schedule (status, scheduled_at);

-- ── As-run log ─────────────────────────────────────────────────────────────
-- Append-only record of what actually went to air. Never updated after insert.
CREATE TABLE IF NOT EXISTS playout_as_run (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  channel_id  UUID        NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
  asset_id    UUID        REFERENCES assets(id) ON DELETE SET NULL,
  item_id     UUID,
  clip_name   TEXT,
  started_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  ended_at    TIMESTAMPTZ,
  duration_s  NUMERIC,
  result      TEXT        NOT NULL DEFAULT 'played',
  CHECK (result IN ('played','skipped','error'))
);

CREATE INDEX IF NOT EXISTS idx_playout_as_run_channel ON playout_as_run (channel_id, started_at DESC);
