package storage

const schema = `
CREATE TABLE IF NOT EXISTS stories (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    guid          TEXT UNIQUE NOT NULL,
    headline      TEXT NOT NULL,
    lede          TEXT,
    content       TEXT,
    content_chars INTEGER NOT NULL DEFAULT 0,
    image_url     TEXT,
    article_url   TEXT NOT NULL,
    url_canonical TEXT,
    headline_norm TEXT,
    source        TEXT NOT NULL,
    platforms     TEXT,
    channel       TEXT,
    classified    INTEGER NOT NULL DEFAULT 0,
    paywalled     INTEGER NOT NULL DEFAULT 0,
    seen_at       INTEGER NOT NULL,
    published_at  INTEGER
);

-- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live
-- board and it replaces this table wholesale. Rows are state that is currently
-- true ("Josie is in holymachina"), which is the one thing the story feed can
-- never be — every dispatch there is an accomplishment, and an accomplishment is
-- a clipping the moment it lands.
--
-- token is gogobee's per-player roster token, not a Matrix handle and not a
-- story GUID. Players who ran "!news optout" are omitted from the snapshot
-- upstream and so never appear here at all.
CREATE TABLE IF NOT EXISTS adventure_roster (
    token       TEXT PRIMARY KEY,
    name        TEXT NOT NULL,
    level       INTEGER NOT NULL DEFAULT 0,
    class_race  TEXT,
    status      TEXT NOT NULL,              -- "expedition" | "idle"
    zone        TEXT,
    region      TEXT,
    day         INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle
    idle_hours  INTEGER NOT NULL DEFAULT 0, -- hours since last player action
    snapshot_at INTEGER NOT NULL            -- when gogobee took the snapshot
);

-- The snapshot time lives outside the rows because an *empty* board is
-- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A
-- MAX(snapshot_at) over zero rows can't tell those apart, and the page must —
-- one is "quiet realm", the other is "the wire is down, trust nothing here".
CREATE TABLE IF NOT EXISTS adventure_roster_meta (
    id          INTEGER PRIMARY KEY CHECK (id = 1),
    snapshot_at INTEGER NOT NULL DEFAULT 0
);

-- adventure_events is the structured residue of a dispatch, and it is the one
-- adventure table that is a *log* rather than a snapshot. That inversion is the
-- point. The roster answers "where is Josie now" and is replaced every tick;
-- this answers "what has Josie ever done", which no snapshot can, because each
-- push throws the last one away.
--
-- It exists because the facts were already arriving and we were burning them.
-- gogobee sends boss/opponent/zone/outcome on every fact; renderAdventure melted
-- them into a sentence and only the sentence was kept, so "how many bosses has
-- she downed" was answerable only by parsing English back out of a headline.
-- These rows are that fact, kept as fact. The story row remains the thing people
-- read; this is the thing we can count.
--
-- Keyed on guid, the same idempotency key as stories: a gogobee retry must not
-- double-count a kill. subject/opponent are *character names*, not roster tokens,
-- because that is what a fact carries and what the feed already prints in public.
-- Names are therefore the join back to a board row (roster.name -> token), which
-- is weaker than an id: a renamed or recycled character takes their history with
-- them. gogobee doesn't put a stable character id on the wire today, and inventing
-- one Pete-side would only be a guess at which two names were the same person.
CREATE TABLE IF NOT EXISTS adventure_events (
    guid        TEXT PRIMARY KEY,          -- == stories.guid; the dispatch this fact rendered into
    event_type  TEXT NOT NULL,
    tier        TEXT,                      -- "priority" | "bulletin"
    subject     TEXT,                      -- character name: the one it happened to
    opponent    TEXT,                      -- character name: the other player, when there is one
    boss        TEXT,                      -- game-authored monster name, not player-controlled
    zone        TEXT,
    region      TEXT,
    level       INTEGER NOT NULL DEFAULT 0,
    tally       INTEGER NOT NULL DEFAULT 0, -- the fact's Count: defenders, days in, ...
    outcome     TEXT,
    milestone   TEXT,
    stakes      TEXT,                      -- free-text noun the fact is about: a bounty, a found treasure's name

    actors      TEXT,                      -- JSON array; the fact-guard allow-list, kept for audit
    occurred_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_adv_events_opponent ON adventure_events(opponent, occurred_at DESC) WHERE opponent IS NOT NULL AND opponent <> '';
CREATE INDEX IF NOT EXISTS idx_adv_events_type ON adventure_events(event_type, occurred_at DESC);

-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
-- ever read for the one authenticated user asking about themselves, so the board
-- stays anonymous and no endpoint hands out anyone else's number. Advisory only —
-- the storefront greys out tiers it thinks you can't afford, but the real debit
-- happens on gogobee at claim time and a stale balance just bounces an order.
CREATE TABLE IF NOT EXISTS user_euro (
    username    TEXT PRIMARY KEY,          -- Matrix localpart == session Username
    euro        REAL NOT NULL DEFAULT 0,
    snapshot_at INTEGER NOT NULL DEFAULT 0
);

-- A mischief contract a buyer placed from the web storefront, on its way to
-- gogobee. Pete never touches money and never runs the game rules — it only
-- records the *intent* and later the verdict gogobee hands back. The status
-- ladder:
--
--   pending -> placed              (gogobee debited the buyer and opened a contract)
--           -> bounced_funds       (buyer couldn't actually afford it)
--           -> bounced_ineligible  (target no longer a valid mark: no expedition,
--                                    a live contract already, cooldown, cap, ...)
--
-- guid is the idempotency key end to end: gogobee passes it to DebitIdem and
-- stamps it on the contract, so a claim whose ack is lost on the wire can be
-- retried without charging the buyer twice or opening two contracts. buyer_sub
-- is the OIDC subject (stable across username changes) and keys "my orders";
-- buyer_username is what gogobee turns into @username:server. target_token is
-- the roster token of the mark — the same anonymous token the board renders, so
-- ordering a hit never needs the victim's real handle.
CREATE TABLE IF NOT EXISTS mischief_orders (
    guid           TEXT PRIMARY KEY,
    buyer_sub      TEXT NOT NULL,
    buyer_username TEXT NOT NULL,
    target_token   TEXT NOT NULL,
    target_name    TEXT NOT NULL,           -- display copy, frozen at order time
    tier           TEXT NOT NULL,
    signed         INTEGER NOT NULL DEFAULT 0, -- 1 = sign openly (+25%), 0 = anonymous
    status         TEXT NOT NULL,           -- see the ladder above
    detail         TEXT,                    -- gogobee's human note on the verdict
    created_at     INTEGER NOT NULL,
    updated_at     INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mischief_orders_pending ON mischief_orders(status, created_at);
CREATE INDEX IF NOT EXISTS idx_mischief_orders_buyer ON mischief_orders(buyer_sub, created_at DESC);

-- The storefront price list. gogobee is the sole authority on prices and pushes
-- the whole catalog on the roster tick, so a fee retune reaches the storefront
-- within a snapshot and Pete never hardcodes a number that can drift. ordinal
-- preserves the grunt->boss order the push arrived in.
CREATE TABLE IF NOT EXISTS mischief_tiers (
    key        TEXT PRIMARY KEY,
    display    TEXT NOT NULL,
    fee        INTEGER NOT NULL,
    signed_fee INTEGER NOT NULL,
    blurb      TEXT,
    ordinal    INTEGER NOT NULL DEFAULT 0
);

-- An equip/unequip an owner asked for from their own detail page, on its way to
-- gogobee. This is the one adventure feature that carries intent *back* to the
-- game box; it does it the mischief way, with no new network route — Pete records
-- the intent, gogobee polls and applies it against its own equipment tables, and
-- pushes back a verdict. The status ladder:
--
--   pending -> applied                  (worn or removed; detail says how)
--           -> rejected_not_owned       (the item left the pack before gogobee got here)
--           -> rejected_not_worn        (unequip of an already-empty slot)
--           -> rejected_not_equippable  (the item has no slot to fill)
--
-- guid is the idempotency key end to end. Note the game action is NOT naturally
-- idempotent (equipping consumes an inventory row), so gogobee short-circuits on
-- this guid before it mutates — unlike mischief, whose action converges on its
-- own. owner_sub is the OIDC subject (stable across renames) and keys "my orders";
-- owner_localpart is the Matrix localpart gogobee turns into the MXID of the
-- character to dress. item_id is the adventure_inventory row id for an equip (the
-- table is AUTOINCREMENT, so a stale id misses cleanly rather than hitting the
-- wrong item); an unequip keys on slot alone. character_name and item_name are
-- frozen display copy gogobee ignores.
CREATE TABLE IF NOT EXISTS equip_orders (
    guid            TEXT PRIMARY KEY,
    owner_sub       TEXT NOT NULL,
    owner_localpart TEXT NOT NULL,
    character_name  TEXT NOT NULL DEFAULT '',
    item_id         INTEGER NOT NULL DEFAULT 0,
    item_name       TEXT NOT NULL DEFAULT '',
    slot            TEXT NOT NULL DEFAULT '',
    action          TEXT NOT NULL,             -- equip / unequip
    status          TEXT NOT NULL,             -- see the ladder above
    detail          TEXT,                      -- gogobee's human note on the verdict
    created_at      INTEGER NOT NULL,
    updated_at      INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_equip_orders_pending ON equip_orders(status, created_at);
CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, created_at DESC);

-- A player's private, owner-only expansion — inventory, vault, house, pets —
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
-- like user_euro, it is only ever served back to the one authenticated user it
-- belongs to, never on the public board. token is that player's current roster
-- token, kept here so the detail page can prove owner↔page by a join without
-- ever reversing the one-way token — the association lives only in this
-- owner-private table and never reaches any public response. detail_json is the
-- {inventory, vault, house, pets} body; it is replaced wholesale each tick, so a
-- player who drops out of gogobee's push loses their stale self-view.
CREATE TABLE IF NOT EXISTS player_self_detail (
    localpart   TEXT PRIMARY KEY,
    token       TEXT NOT NULL,
    detail_json TEXT NOT NULL,
    snapshot_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);

CREATE TABLE IF NOT EXISTS post_log (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    guid          TEXT NOT NULL,
    channel       TEXT NOT NULL,
    event_id      TEXT,
    url_canonical TEXT,
    posted_at     INTEGER NOT NULL,
    forced        INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS round_robin_state (
    id           INTEGER PRIMARY KEY CHECK (id = 1),
    last_channel TEXT,
    last_tick_at INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS reactions (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    post_guid  TEXT NOT NULL,
    channel    TEXT NOT NULL,
    event_id   TEXT NOT NULL,
    emoji      TEXT NOT NULL,
    user_id    TEXT NOT NULL,
    reacted_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS user_preferences (
    user_sub   TEXT PRIMARY KEY,
    prefs      TEXT NOT NULL,
    username   TEXT,
    email      TEXT,
    updated_at INTEGER NOT NULL
);

-- Per-user read + bookmark state for signed-in visitors, keyed by OIDC subject.
-- One row carries both signals; a NULL timestamp means "not set". A row with
-- both timestamps NULL is meaningless and is pruned, so presence of a row means
-- the story is read, bookmarked, or both.
CREATE TABLE IF NOT EXISTS user_story_state (
    user_sub      TEXT NOT NULL,
    story_id      INTEGER NOT NULL,
    read_at       INTEGER,
    bookmarked_at INTEGER,
    PRIMARY KEY (user_sub, story_id)
);

-- Aggregate web usage. page_views holds running view counts keyed by a coarse
-- path label ("home", channel slug, …) and the UTC day, so we can report both
-- all-time totals and per-day breakdowns without storing any per-request rows.
CREATE TABLE IF NOT EXISTS page_views (
    path  TEXT NOT NULL,
    day   INTEGER NOT NULL,           -- unix day (floor(unix / 86400)), UTC
    views INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (path, day)
);

-- Per-source poll health, one row per configured feed (keyed by source name).
-- Written on every poll (success and failure) so the owner-facing dashboard can
-- show which feeds are healthy without keeping the poller's in-memory state.
-- last_success_at / last_item_count survive failures so a broken feed still
-- shows when it last worked and how much it last returned.
CREATE TABLE IF NOT EXISTS source_health (
    source               TEXT PRIMARY KEY,
    last_poll_at         INTEGER,           -- unix, most recent poll attempt
    last_success_at      INTEGER,           -- unix, most recent successful fetch
    last_error           TEXT,              -- last failure message ('' when healthy)
    consecutive_failures INTEGER NOT NULL DEFAULT 0,
    last_item_count      INTEGER NOT NULL DEFAULT 0, -- items in the last successful fetch
    updated_at           INTEGER NOT NULL
);

-- Web Push subscriptions for signed-in users, one row per browser/device
-- endpoint. p256dh + auth are the client's encryption keys (RFC 8291); the
-- server needs them to encrypt each push. last_notified_at is the per-endpoint
-- digest watermark: the sender only counts stories seen after it. A user can
-- have several endpoints (phone, desktop) — each is notified independently.
CREATE TABLE IF NOT EXISTS push_subscriptions (
    endpoint         TEXT PRIMARY KEY,
    user_sub         TEXT NOT NULL,
    p256dh           TEXT NOT NULL,
    auth             TEXT NOT NULL,
    created_at       INTEGER NOT NULL,
    last_notified_at INTEGER NOT NULL
);

-- Privacy-preserving daily unique estimate. visitor is a salted hash of
-- IP+User-Agent; the salt rotates every UTC day and is never persisted, so the
-- hashes are irreversible and cannot be linked across days. We keep only enough
-- to dedup within a single day, then prune.
CREATE TABLE IF NOT EXISTS daily_visitors (
    day     INTEGER NOT NULL,
    visitor TEXT NOT NULL,
    PRIMARY KEY (day, visitor)
);

-- Per-story read counts, keyed by story id and UTC day. Incremented whenever a
-- visitor opens a story in reader mode (/api/article). The day dimension lets
-- us surface "popular this week" without a separate rollup; summing across all
-- days gives the all-time count shown on cards. Rows age out with their story
-- via the foreign-key-less prune in RunMaintenance.
CREATE TABLE IF NOT EXISTS story_views (
    story_id INTEGER NOT NULL,
    day      INTEGER NOT NULL,           -- unix day (floor(unix / 86400)), UTC
    views    INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (story_id, day)
);

-- ---------------------------------------------------------------------------
-- games.parodia.dev
--
-- The invariant the whole casino rests on: a euro is either in gogobee's
-- euro_balances or in Pete's chip escrow, never both. It crosses between them
-- only via a GUID-idempotent claim, and Pete never writes a euro balance —
-- gogobee does, when it claims the escrow row and tells us how it went.
-- ---------------------------------------------------------------------------

-- A player's chips: euros that have crossed into the casino and haven't crossed
-- back yet. 1:1 with euros. Keyed by Matrix user id, because that's the identity
-- gogobee's ledger uses and the one an Authentik username maps onto.
CREATE TABLE IF NOT EXISTS game_chips (
    matrix_user  TEXT PRIMARY KEY,
    chips        INTEGER NOT NULL DEFAULT 0,
    -- Advisory only, and stale by design: the last euro balance gogobee told us
    -- about. Displayed, never trusted. The authoritative check is the debit at
    -- claim time, which happens on gogobee's box against gogobee's ledger.
    euro_balance REAL,
    last_played  INTEGER NOT NULL DEFAULT 0,  -- unix; the reaper reads this
    updated_at   INTEGER NOT NULL DEFAULT 0
);

-- One crossing of the euro/chip border, in either direction.
--
--   requested -> claimed -> funded     (buy-in: gogobee debited, chips spendable)
--                        -> rejected   (buy-in: insufficient funds, no chips)
--   requested -> claimed -> settled    (cash-out: chips gone, euros credited)
--
-- The guid is the idempotency key end to end: it's what gogobee passes to
-- DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be retried
-- without the player paying twice.
CREATE TABLE IF NOT EXISTS game_escrow (
    guid          TEXT PRIMARY KEY,
    matrix_user   TEXT NOT NULL,
    kind          TEXT NOT NULL,             -- 'buyin' | 'cashout'
    amount        INTEGER NOT NULL,          -- euros == chips
    state         TEXT NOT NULL,             -- see the ladder above
    reason        TEXT,                      -- 'insufficient_funds', when rejected
    balance_after REAL,                      -- gogobee's euro balance after the move
    created_at    INTEGER NOT NULL,
    claimed_at    INTEGER,                   -- when gogobee took it; drives the re-poll
    updated_at    INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_game_escrow_state ON game_escrow(state, created_at);
CREATE INDEX IF NOT EXISTS idx_game_escrow_user ON game_escrow(matrix_user, created_at DESC);

-- Every hand played, for money. This is the audit trail: seeds so a disputed
-- hand can be re-dealt exactly as it fell, rake so the house's take is
-- accountable, and enough shape to answer "how fast is this economy actually
-- moving" before the answer becomes a problem.
CREATE TABLE IF NOT EXISTS game_hands (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    matrix_user TEXT NOT NULL,
    game        TEXT NOT NULL,               -- 'blackjack'
    bet         INTEGER NOT NULL,
    payout      INTEGER NOT NULL,            -- chips returned, net of rake
    rake        INTEGER NOT NULL,
    outcome     TEXT NOT NULL,
    seed1       INTEGER NOT NULL,            -- the shoe, reproducible
    seed2       INTEGER NOT NULL,
    played_at   INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_game_hands_user ON game_hands(matrix_user, played_at DESC);
CREATE INDEX IF NOT EXISTS idx_game_hands_played ON game_hands(played_at);

-- The hand a player is in the middle of. One per player: you cannot be dealt a
-- second hand while chips are riding on the first.
--
-- The state column is the engine's State, serialized whole — shoe included. It
-- lives here rather than in memory because Pete redeploys often, and a player
-- whose stake has already been taken must find their cards where they left them
-- rather than a table that has forgotten them. It is also why the deck never
-- goes to the browser: the authoritative shoe is this row, on the server.
CREATE TABLE IF NOT EXISTS game_live_hands (
    matrix_user TEXT PRIMARY KEY,
    game        TEXT NOT NULL,               -- 'blackjack'
    state       TEXT NOT NULL,               -- JSON: the engine's State
    seed1       INTEGER NOT NULL,            -- carried to the audit log when it settles
    seed2       INTEGER NOT NULL,
    -- Set when the player is sitting at a shared table rather than playing alone.
    -- The engine state then lives in game_tables.state, not here, and this row is
    -- purely the occupancy claim: its PRIMARY KEY is what stops one player being
    -- in two games at once, and it is the row the cash-out check reads. Making
    -- game_seats a second uniqueness domain instead would be a split brain — see
    -- the comment on game_seats.
    table_id    TEXT,
    updated_at  INTEGER NOT NULL
);

-- ---------------------------------------------------------------------------
-- Shared tables: the casino with more than one person at it.
-- ---------------------------------------------------------------------------

-- A table other people can sit at. The state column is the engine's State,
-- exactly as game_live_hands holds it for a solo game — one blob for the whole
-- felt, because a pot is not divisible into per-player rows.
--
-- version is the concurrency authority, and the mutex in the web layer is only
-- an optimisation on top of it. Every state write is a conditional UPDATE
-- against the version the writer read; zero rows affected means somebody moved
-- first. This has to live in the database rather than in a mutex map because a
-- mutex does not survive a redeploy — during a drain, two processes hold two
-- different mutexes over the same row and both believe they are alone.
CREATE TABLE IF NOT EXISTS game_tables (
    id         TEXT PRIMARY KEY,
    game       TEXT NOT NULL,                -- 'holdem' | 'uno' | 'blackjack'
    tier       TEXT NOT NULL,                -- the stake, as that game names it
    state      TEXT NOT NULL,                -- JSON: the engine's State
    seed1      INTEGER NOT NULL,
    seed2      INTEGER NOT NULL,
    phase      TEXT NOT NULL,                -- the engine's phase, lifted out so the lobby can read it
    hand_no    INTEGER NOT NULL DEFAULT 0,   -- with id, the identity of one hand: the payout key
    version    INTEGER NOT NULL DEFAULT 0,
    -- Unix seconds by which the seat to act must act, or 0 for no clock. The turn
    -- clock scans this. It is set only when the turn lands on a human: bots resolve
    -- inside ApplyMove and are never waited for.
    deadline   INTEGER NOT NULL DEFAULT 0,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_game_tables_due ON game_tables(deadline) WHERE deadline > 0;
CREATE INDEX IF NOT EXISTS idx_game_tables_lobby ON game_tables(game, updated_at DESC);

-- Who is sitting where. A seat with no matrix_user is a bot.
--
-- This is deliberately *not* a uniqueness domain for players: there is no unique
-- index on matrix_user, and there must not be one. Occupancy is decided by
-- game_live_hands' primary key, which already stops a player being in two games,
-- already makes a double-clicked join a 409, and is already what the cash-out
-- check reads. A second domain that could disagree with the first would silently
-- switch all three off — the worst of them being a player who cashes out to zero
-- while sitting at a poker table with chips in the pot.
--
-- staked is what the player brought to the table and has not yet taken home. It
-- is the chip-conservation anchor: the chips are off their game_chips stack and
-- inside the table blob, where the idle reaper cannot see them.
CREATE TABLE IF NOT EXISTS game_seats (
    table_id    TEXT NOT NULL,
    seat        INTEGER NOT NULL,
    matrix_user TEXT,                        -- NULL for a bot
    name        TEXT NOT NULL,
    staked      INTEGER NOT NULL DEFAULT 0,
    -- Set once a human's clock has run out on them. An absent human is not a bot,
    -- but the bot loop has to be allowed past their seat or a table with three
    -- ghosts spends a minute an orbit folding air. They come back the moment they act.
    away        INTEGER NOT NULL DEFAULT 0,
    last_seen   INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (table_id, seat)
);
CREATE INDEX IF NOT EXISTS idx_game_seats_user ON game_seats(matrix_user) WHERE matrix_user IS NOT NULL;

-- There is no payout ledger here, and its absence is deliberate — the design
-- called for one and the money model made it unnecessary. Chips cross into a
-- table when a player sits down and back out when they get up; a hand ending
-- moves the pot *within* the state blob and credits nobody's game_chips row. So
-- there is no money write to make idempotent: a settle is a state write,
-- conditional on the version, and a replayed one affects zero rows and rolls
-- back. See the header of internal/storage/tables.go.

-- Chat on the felt. Messages only — no typing indicators, which is the one thing
-- that would have justified a socket. It does not mirror into Matrix.
--
-- hand_no is kept against every line for a reason: at a table of real people,
-- collusion looks like chat, and the only way to ever answer that question is to
-- be able to read what was said during the hand it was said in.
CREATE TABLE IF NOT EXISTS game_chat (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    table_id    TEXT NOT NULL,
    hand_no     INTEGER NOT NULL,
    matrix_user TEXT,                        -- NULL when the house is talking
    name        TEXT NOT NULL,
    body        TEXT NOT NULL,
    said_at     INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_game_chat_table ON game_chat(table_id, id);

-- The trivia bank: questions pulled from the Open Trivia Database ahead of time,
-- so that asking one is a local read.
--
-- Prefetched rather than fetched per question because a trivia ladder asks a
-- question every fifteen seconds with money on a clock the player is scored
-- against. A live fetch would put somebody else's latency and rate limit inside
-- that clock. The refill is a slow background drip (internal/opentdb); a round
-- never waits on it.
--
-- The question text is UNIQUE, which is the whole dedup strategy: OpenTDB hands back
-- overlapping batches and the bank would otherwise fill up with the same forty
-- questions. correct/incorrect are stored as the API gives them; the *shuffle*
-- happens in the engine, per game, against that game's seed — so where the right
-- answer sits in this table tells a player nothing.
CREATE TABLE IF NOT EXISTS trivia_questions (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    difficulty TEXT NOT NULL,                -- 'easy' | 'medium' | 'hard'
    category   TEXT NOT NULL,
    question   TEXT NOT NULL UNIQUE,
    correct    TEXT NOT NULL,
    incorrect  TEXT NOT NULL,                -- JSON array of the three wrong answers
    fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trivia_difficulty ON trivia_questions(difficulty);

CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
CREATE INDEX IF NOT EXISTS idx_post_log_canonical_channel ON post_log(url_canonical, channel, posted_at);
CREATE INDEX IF NOT EXISTS idx_stories_classified_source ON stories(classified, source);
CREATE INDEX IF NOT EXISTS idx_stories_channel_classified_seen ON stories(channel, classified, seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_stories_classified_seen ON stories(classified, seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_stories_image_url ON stories(image_url) WHERE image_url IS NOT NULL AND image_url <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_stories_url_canonical ON stories(url_canonical) WHERE url_canonical IS NOT NULL AND url_canonical <> '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_stories_source_headline_norm ON stories(source, headline_norm) WHERE headline_norm IS NOT NULL AND headline_norm <> '';
CREATE INDEX IF NOT EXISTS idx_reactions_post_guid ON reactions(post_guid);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reactions_event_id ON reactions(event_id);
CREATE INDEX IF NOT EXISTS idx_page_views_day ON page_views(day);
CREATE INDEX IF NOT EXISTS idx_daily_visitors_day ON daily_visitors(day);
CREATE INDEX IF NOT EXISTS idx_story_views_day ON story_views(day);
CREATE INDEX IF NOT EXISTS idx_user_state_bookmarks ON user_story_state(user_sub, bookmarked_at) WHERE bookmarked_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_user_state_reads ON user_story_state(user_sub, read_at) WHERE read_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_sub);
`

const ftsSchema = `
CREATE VIRTUAL TABLE stories_fts USING fts5(
    guid UNINDEXED,
    headline,
    lede,
    source UNINDEXED,
    platforms UNINDEXED,
    content='stories',
    content_rowid='id'
);
`

const ftsTriggers = `
CREATE TRIGGER stories_fts_insert AFTER INSERT ON stories BEGIN
    INSERT INTO stories_fts(rowid, guid, headline, lede, source, platforms)
    VALUES (new.id, new.guid, new.headline, new.lede, new.source, new.platforms);
END;

CREATE TRIGGER stories_fts_delete AFTER DELETE ON stories BEGIN
    INSERT INTO stories_fts(stories_fts, rowid, guid, headline, lede, source, platforms)
    VALUES ('delete', old.id, old.guid, old.headline, old.lede, old.source, old.platforms);
END;

CREATE TRIGGER stories_fts_update AFTER UPDATE ON stories BEGIN
    INSERT INTO stories_fts(stories_fts, rowid, guid, headline, lede, source, platforms)
    VALUES ('delete', old.id, old.guid, old.headline, old.lede, old.source, old.platforms);
    INSERT INTO stories_fts(rowid, guid, headline, lede, source, platforms)
    VALUES (new.id, new.guid, new.headline, new.lede, new.source, new.platforms);
END;
`
