-- +goose Up
-- Baseline schema for the Scrabble backend service, consolidating the incremental
-- migration history into a single starting point (there is no production data yet,
-- so the squash carries no data migration). Every backend object lives in the
-- `backend` schema; it is created here so a fresh database can apply this migration,
-- and search_path is pinned for the rest of the file so unqualified CREATE
-- statements land in `backend`. Production also pins search_path via
-- BACKEND_POSTGRES_DSN.
CREATE SCHEMA IF NOT EXISTS backend;
SET search_path = backend, pg_catalog;

-- Durable internal accounts. A guest is a durable row with is_guest set and no
-- identity, excluded from profile/friends/stats/history. The away window (one
-- interval per day, in the account's time_zone) is honoured by the turn-timeout
-- sweeper and the robot's sleep; hint_balance is the purchasable-hint wallet.
-- service_language records the language tag of the bot a Telegram user last
-- authenticated through (out-of-app push routing), distinct from preferred_language
-- (the interface language). merged_into/merged_at turn a merged-away secondary into
-- an audit tombstone; paid_account is a forward-looking one-time-payment marker.
CREATE TABLE accounts (
    account_id                uuid        PRIMARY KEY,
    display_name              text        NOT NULL DEFAULT '',
    preferred_language        text        NOT NULL DEFAULT 'en',
    time_zone                 text        NOT NULL DEFAULT 'UTC',
    block_chat                boolean     NOT NULL DEFAULT false,
    block_friend_requests     boolean     NOT NULL DEFAULT false,
    created_at                timestamptz NOT NULL DEFAULT now(),
    updated_at                timestamptz NOT NULL DEFAULT now(),
    away_start                time        NOT NULL DEFAULT '00:00',
    away_end                  time        NOT NULL DEFAULT '07:00',
    hint_balance              integer     NOT NULL DEFAULT 0,
    is_guest                  boolean     NOT NULL DEFAULT false,
    notifications_in_app_only boolean     NOT NULL DEFAULT true,
    paid_account              boolean     NOT NULL DEFAULT false,
    merged_into               uuid        REFERENCES accounts (account_id) ON DELETE SET NULL,
    merged_at                 timestamptz,
    service_language          text        CHECK (service_language IN ('en', 'ru')),
    CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru')),
    CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0)
);

-- Platform and email identities attached to an account. external_id is the platform
-- user id (kind='telegram'), the email address (kind='email') or the robot name
-- (kind='robot'); confirmed flips true once an email confirm-code is verified.
CREATE TABLE identities (
    identity_id uuid        PRIMARY KEY,
    account_id  uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    kind        text        NOT NULL,
    external_id text        NOT NULL,
    confirmed   boolean     NOT NULL DEFAULT false,
    created_at  timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot')),
    CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id)
);
CREATE INDEX identities_account_idx ON identities (account_id);

-- Opaque server sessions. token_hash is the hex-encoded SHA-256 of the bearer token;
-- the plaintext token is never stored. Sessions are revoke-only (no TTL): status
-- moves active -> revoked and revoked_at is stamped.
CREATE TABLE sessions (
    session_id   uuid        PRIMARY KEY,
    account_id   uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    token_hash   text        NOT NULL,
    status       text        NOT NULL DEFAULT 'active',
    created_at   timestamptz NOT NULL DEFAULT now(),
    last_seen_at timestamptz,
    revoked_at   timestamptz,
    CONSTRAINT sessions_status_chk CHECK (status IN ('active', 'revoked')),
    CONSTRAINT sessions_token_hash_key UNIQUE (token_hash)
);
CREATE INDEX sessions_account_idx ON sessions (account_id);

-- One match. The live position is event-sourced: this row carries the pinned
-- dictionary, the bag seed and the denormalised turn cursor the sweeper needs, while
-- game_moves is the append-only journal the in-memory engine.Game is replayed from
-- (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move clock; its allowed
-- values are enforced in Go. variant uses engine.Variant's stable labels.
CREATE TABLE games (
    game_id           uuid        PRIMARY KEY,
    variant           text        NOT NULL,
    dict_version      text        NOT NULL,
    seed              bigint      NOT NULL,
    status            text        NOT NULL DEFAULT 'active',
    players           smallint    NOT NULL,
    to_move           smallint    NOT NULL DEFAULT 0,
    turn_started_at   timestamptz NOT NULL DEFAULT now(),
    turn_timeout_secs integer     NOT NULL,
    hints_allowed     boolean     NOT NULL DEFAULT true,
    hints_per_player  smallint    NOT NULL DEFAULT 1,
    move_count        integer     NOT NULL DEFAULT 0,
    end_reason        text,
    created_at        timestamptz NOT NULL DEFAULT now(),
    updated_at        timestamptz NOT NULL DEFAULT now(),
    finished_at       timestamptz,
    dropout_tiles     text        NOT NULL DEFAULT 'remove',
    CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
    CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
    CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
    CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
    CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
    CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0),
    CONSTRAINT games_end_reason_chk CHECK (
        end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
    ),
    CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'))
);
-- The sweeper scans active games oldest-turn-first; a partial index keeps it off the
-- finished archive.
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';

-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
-- durable account. score is the running/final score, is_winner is stamped on finish
-- (false for every seat on a draw), hints_used counts the per-game allowance consumed
-- before the profile wallet.
CREATE TABLE game_players (
    game_id    uuid     NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    seat       smallint NOT NULL,
    account_id uuid     NOT NULL REFERENCES accounts (account_id),
    score      integer  NOT NULL DEFAULT 0,
    hints_used smallint NOT NULL DEFAULT 0,
    is_winner  boolean  NOT NULL DEFAULT false,
    PRIMARY KEY (game_id, seat),
    CONSTRAINT game_players_account_key UNIQUE (game_id, account_id)
);
CREATE INDEX game_players_account_idx ON game_players (account_id);

-- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md §9.1).
-- seq orders the moves from 0. payload holds the decoded values needed to both replay
-- the game through the engine and emit GCG without a dictionary. score / running_total
-- / exchanged_count are lifted out for cheap history rendering.
CREATE TABLE game_moves (
    game_id         uuid        NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    seq             integer     NOT NULL,
    seat            smallint    NOT NULL,
    action          text        NOT NULL,
    score           integer     NOT NULL DEFAULT 0,
    running_total   integer     NOT NULL DEFAULT 0,
    exchanged_count smallint    NOT NULL DEFAULT 0,
    payload         text        NOT NULL DEFAULT '{}',
    created_at      timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (game_id, seq),
    CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout'))
);

-- Word-check complaints captured in the context of a game's pinned dictionary. The
-- admin review queue resolves them with a disposition that also feeds the offline
-- dictionary-rebuild pipeline: an accepted complaint records whether the word is to be
-- added or removed, and is marked applied once a rebuilt version is hot-reloaded.
CREATE TABLE complaints (
    complaint_id       uuid        PRIMARY KEY,
    complainant_id     uuid        NOT NULL REFERENCES accounts (account_id),
    game_id            uuid        NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    variant            text        NOT NULL,
    dict_version       text        NOT NULL,
    word               text        NOT NULL,
    was_valid          boolean     NOT NULL,
    note               text        NOT NULL DEFAULT '',
    status             text        NOT NULL DEFAULT 'open',
    created_at         timestamptz NOT NULL DEFAULT now(),
    disposition        text        NOT NULL DEFAULT '',
    resolution_note    text        NOT NULL DEFAULT '',
    resolved_at        timestamptz,
    applied_in_version text        NOT NULL DEFAULT '',
    CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')),
    CONSTRAINT complaints_disposition_chk
        CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove'))
);
CREATE INDEX complaints_status_idx ON complaints (status);

-- Per-account lifetime statistics, recomputed incrementally on each game finish.
-- Guests have no durable stats. A draw increments draws only. max_word_points is the
-- best single move score (folding in every word the move formed and the all-tiles bonus).
CREATE TABLE account_stats (
    account_id      uuid        PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE,
    wins            integer     NOT NULL DEFAULT 0,
    losses          integer     NOT NULL DEFAULT 0,
    draws           integer     NOT NULL DEFAULT 0,
    max_game_points integer     NOT NULL DEFAULT 0,
    max_word_points integer     NOT NULL DEFAULT 0,
    updated_at      timestamptz NOT NULL DEFAULT now()
);

-- The friend graph. A row is created by the requester as 'pending' and flipped to
-- 'accepted' by the addressee; an explicit 'declined' is remembered (anti-spam),
-- while cancelling or unfriending deletes the row. Friendship is symmetric.
CREATE TABLE friendships (
    requester_id uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    addressee_id uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    status       text        NOT NULL DEFAULT 'pending',
    created_at   timestamptz NOT NULL DEFAULT now(),
    responded_at timestamptz,
    PRIMARY KEY (requester_id, addressee_id),
    CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined')),
    CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
);
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);

-- Per-user blocks. The effect is applied mutually by the social checks (a block in
-- either direction suppresses chat visibility and prevents requests/invitations).
CREATE TABLE blocks (
    blocker_id uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    blocked_id uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    created_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (blocker_id, blocked_id),
    CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id)
);
CREATE INDEX blocks_blocked_idx ON blocks (blocked_id);

-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty body,
-- so one journal carries both chatter and nudges. body is capped at 60 runes (enforced
-- again in Go, where the content filter also rejects links/emails/phone numbers).
-- sender_ip holds the gateway-forwarded client IP as a validated string. Chat is part
-- of the game archive and cascades away only with its game.
CREATE TABLE chat_messages (
    message_id uuid        PRIMARY KEY,
    game_id    uuid        NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    sender_id  uuid        NOT NULL REFERENCES accounts (account_id),
    kind       text        NOT NULL DEFAULT 'message',
    body       text        NOT NULL DEFAULT '',
    sender_ip  text,
    created_at timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')),
    CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60),
    CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '')
);
CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at);
-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender).
CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at)
    WHERE kind = 'nudge';

-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the 6-digit code
-- (the plaintext is never stored); expires_at bounds the TTL and attempts caps brute
-- force. A row is consumed (consumed_at stamped) on success.
CREATE TABLE email_confirmations (
    confirmation_id uuid        PRIMARY KEY,
    account_id      uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    email           text        NOT NULL,
    code_hash       text        NOT NULL,
    expires_at      timestamptz NOT NULL,
    attempts        smallint    NOT NULL DEFAULT 0,
    consumed_at     timestamptz,
    created_at      timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0)
);
CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id);

-- A friend-game invitation. The inviter (seat 0) proposes the game settings to 1..3
-- invitees; the game starts only when every invitee has accepted, and any decline
-- cancels the whole invitation. Lazily expired after expires_at (no background sweep).
-- game_id is set when the game is started.
CREATE TABLE game_invitations (
    invitation_id     uuid        PRIMARY KEY,
    inviter_id        uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    variant           text        NOT NULL,
    turn_timeout_secs integer     NOT NULL,
    hints_allowed     boolean     NOT NULL DEFAULT true,
    hints_per_player  smallint    NOT NULL DEFAULT 1,
    dropout_tiles     text        NOT NULL DEFAULT 'remove',
    status            text        NOT NULL DEFAULT 'pending',
    game_id           uuid        REFERENCES games (game_id) ON DELETE SET NULL,
    expires_at        timestamptz NOT NULL,
    created_at        timestamptz NOT NULL DEFAULT now(),
    updated_at        timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
    CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')),
    CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')),
    CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0),
    CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0)
);
CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id);

-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's seat in
-- the started game (1..3, in invitation order). response tracks each invitee's decision.
CREATE TABLE game_invitation_invitees (
    invitation_id uuid        NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE,
    account_id    uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    seat          smallint    NOT NULL,
    response      text        NOT NULL DEFAULT 'pending',
    responded_at  timestamptz,
    PRIMARY KEY (invitation_id, account_id),
    CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')),
    CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3)
);
CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id);

-- One-time friend codes. The player who wants to be added issues a 6-digit code;
-- whoever enters it becomes their friend. Only the SHA-256 hash is stored; expires_at
-- bounds the 12h TTL and consumed_at marks single use. At most one live code per issuer.
CREATE TABLE friend_codes (
    code_id     uuid        PRIMARY KEY,
    account_id  uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    code_hash   text        NOT NULL,
    expires_at  timestamptz NOT NULL,
    consumed_at timestamptz,
    created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX friend_codes_account_idx ON friend_codes (account_id);
CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash);

-- Per-(game, account) draft the server persists across reloads and devices: the
-- player's preferred rack tile order and the tiles laid on the board but not yet
-- submitted. board_tiles is reset when an opponent's committed move overlaps a cell.
CREATE TABLE game_drafts (
    game_id     uuid        NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    account_id  uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    rack_order  text        NOT NULL DEFAULT '',
    board_tiles jsonb       NOT NULL DEFAULT '[]',
    updated_at  timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (game_id, account_id)
);

-- Per-account hidden games. A row hides game_id from account_id's own "my games" list,
-- leaving it visible to the other players. Only finished games are hidden, and the
-- action is irreversible by design (there is no un-hide).
CREATE TABLE game_hidden (
    account_id uuid        NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
    game_id    uuid        NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    created_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (account_id, game_id)
);

-- +goose Down
DROP SCHEMA IF EXISTS backend CASCADE;
