-- +goose Up
-- Initial schema for the consolidated Galaxy backend service.
--
-- Every backend table lives in the `backend` schema. The schema is created
-- here so a fresh deployment can apply this migration against an empty
-- database, and search_path is pinned for the rest of the migration so
-- subsequent CREATE statements land in `backend` without needing to qualify
-- every object. Production deployments additionally pin search_path through
-- BACKEND_POSTGRES_DSN.

CREATE SCHEMA IF NOT EXISTS backend;
SET search_path = backend, pg_catalog;

-- =====================================================================
-- Auth domain
-- =====================================================================

CREATE TABLE device_sessions (
    device_session_id  uuid        PRIMARY KEY,
    user_id            uuid        NOT NULL,
    client_public_key  bytea       NOT NULL,
    status             text        NOT NULL,
    created_at         timestamptz NOT NULL DEFAULT now(),
    last_seen_at       timestamptz,
    revoked_at         timestamptz,
    CONSTRAINT device_sessions_status_chk
        CHECK (status IN ('active', 'revoked', 'blocked'))
);

CREATE INDEX device_sessions_user_idx   ON device_sessions (user_id);
CREATE INDEX device_sessions_status_idx ON device_sessions (status);

CREATE TABLE auth_challenges (
    challenge_id        uuid        PRIMARY KEY,
    email               text        NOT NULL,
    code_hash           bytea       NOT NULL,
    attempts            integer     NOT NULL DEFAULT 0,
    created_at          timestamptz NOT NULL DEFAULT now(),
    expires_at          timestamptz NOT NULL,
    consumed_at         timestamptz,
    preferred_language  text        NOT NULL DEFAULT ''
);

CREATE INDEX auth_challenges_email_idx ON auth_challenges (email);

CREATE TABLE blocked_emails (
    email       text        PRIMARY KEY,
    reason      text        NOT NULL,
    blocked_at  timestamptz NOT NULL DEFAULT now()
);

-- session_revocations is the durable audit trail of every device-session
-- revocation. Each revoke writes one row carrying the actor kind, actor
-- id, and free-form reason. The table is append-only; reading it is the
-- only way to answer "who and why revoked this session". The
-- device_session_id column is not a foreign key because device_sessions
-- rows survive after revoke (status='revoked'), and dropping a session
-- through a future cleanup must not implicitly drop its audit history.
CREATE TABLE session_revocations (
    revocation_id      uuid        PRIMARY KEY,
    device_session_id  uuid        NOT NULL,
    user_id            uuid        NOT NULL,
    actor_kind         text        NOT NULL,
    actor_user_id      uuid,
    actor_username     text,
    reason             text        NOT NULL DEFAULT '',
    revoked_at         timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT session_revocations_actor_chk
        CHECK (actor_user_id IS NULL OR actor_username IS NULL)
);

CREATE INDEX session_revocations_user_idx          ON session_revocations (user_id, revoked_at DESC);
CREATE INDEX session_revocations_device_idx        ON session_revocations (device_session_id, revoked_at DESC);
CREATE INDEX session_revocations_actor_kind_idx    ON session_revocations (actor_kind, revoked_at DESC);

-- =====================================================================
-- User domain
-- =====================================================================

-- accounts is the editable source of truth for user identity. email and
-- user_name remain UNIQUE for live and soft-deleted records: emails are
-- never reassigned to a fresh user_id after soft delete, and user_name is
-- immutable for the lifetime of the account.
CREATE TABLE accounts (
    user_id             uuid        PRIMARY KEY,
    email               text        NOT NULL,
    user_name           text        NOT NULL,
    display_name        text        NOT NULL DEFAULT '',
    preferred_language  text        NOT NULL,
    time_zone           text        NOT NULL,
    declared_country    text,
    permanent_block         boolean     NOT NULL DEFAULT false,
    deleted_actor_type      text,
    deleted_actor_user_id   uuid,
    deleted_actor_username  text,
    created_at              timestamptz NOT NULL DEFAULT now(),
    updated_at              timestamptz NOT NULL DEFAULT now(),
    deleted_at              timestamptz,
    CONSTRAINT accounts_email_unique     UNIQUE (email),
    CONSTRAINT accounts_user_name_unique UNIQUE (user_name),
    CONSTRAINT accounts_deleted_actor_chk
        CHECK (deleted_actor_user_id IS NULL OR deleted_actor_username IS NULL)
);

CREATE INDEX accounts_listing_idx
    ON accounts (created_at DESC, user_id DESC);

CREATE INDEX accounts_declared_country_idx
    ON accounts (declared_country)
    WHERE declared_country IS NOT NULL;

-- entitlement_records is the immutable history of entitlement events. tier
-- is constrained to the closed MVP set (free, monthly, yearly, permanent) so
-- the storage layer rejects typos before the user-package validator can.
-- Audit columns (actor_*, reason_code, starts_at, ends_at) mirror the
-- shape used by sanction_records/limit_records: the *_active rollup carries
-- only the binding, the records table is the durable audit log.
CREATE TABLE entitlement_records (
    record_id       uuid        PRIMARY KEY,
    user_id         uuid        NOT NULL REFERENCES accounts (user_id),
    tier            text        NOT NULL,
    is_paid         boolean     NOT NULL,
    source          text        NOT NULL,
    actor_type      text        NOT NULL,
    actor_user_id   uuid,
    actor_username  text,
    reason_code     text        NOT NULL DEFAULT '',
    starts_at       timestamptz NOT NULL DEFAULT now(),
    ends_at         timestamptz,
    created_at      timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT entitlement_records_tier_chk
        CHECK (tier IN ('free', 'monthly', 'yearly', 'permanent')),
    CONSTRAINT entitlement_records_actor_chk
        CHECK (actor_user_id IS NULL OR actor_username IS NULL)
);

CREATE INDEX entitlement_records_user_idx
    ON entitlement_records (user_id, created_at DESC);

-- entitlement_snapshots is the read-optimised current entitlement state.
-- Exactly one row per user_id; updated atomically with new
-- entitlement_records by the user lifecycle store. Audit columns are
-- denormalised from the latest entitlement_records row so the read path
-- needs no join to render the AccountResponse.entitlement payload.
CREATE TABLE entitlement_snapshots (
    user_id                    uuid        PRIMARY KEY REFERENCES accounts (user_id),
    tier                       text        NOT NULL,
    is_paid                    boolean     NOT NULL,
    source                     text        NOT NULL,
    actor_type                 text        NOT NULL,
    actor_user_id              uuid,
    actor_username             text,
    reason_code                text        NOT NULL DEFAULT '',
    starts_at                  timestamptz NOT NULL,
    ends_at                    timestamptz,
    max_registered_race_names  integer     NOT NULL,
    updated_at                 timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT entitlement_snapshots_tier_chk
        CHECK (tier IN ('free', 'monthly', 'yearly', 'permanent')),
    CONSTRAINT entitlement_snapshots_actor_chk
        CHECK (actor_user_id IS NULL OR actor_username IS NULL)
);

CREATE TABLE sanction_records (
    record_id              uuid        PRIMARY KEY,
    user_id                uuid        NOT NULL REFERENCES accounts (user_id),
    sanction_code          text        NOT NULL,
    scope                  text        NOT NULL,
    reason_code            text        NOT NULL,
    actor_type             text        NOT NULL,
    actor_user_id          uuid,
    actor_username         text,
    applied_at             timestamptz NOT NULL DEFAULT now(),
    expires_at             timestamptz,
    removed_at             timestamptz,
    removed_by_type        text,
    removed_by_user_id     uuid,
    removed_by_username    text,
    removed_reason_code    text,
    CONSTRAINT sanction_records_code_chk
        CHECK (sanction_code IN ('permanent_block')),
    CONSTRAINT sanction_records_actor_chk
        CHECK (actor_user_id IS NULL OR actor_username IS NULL),
    CONSTRAINT sanction_records_removed_by_chk
        CHECK (removed_by_user_id IS NULL OR removed_by_username IS NULL)
);

CREATE INDEX sanction_records_user_idx
    ON sanction_records (user_id, applied_at DESC);

-- sanction_active stores the at-most-one active record per
-- (user_id, sanction_code), maintained by the user lifecycle store in
-- the same transaction as the corresponding sanction_records mutation.
CREATE TABLE sanction_active (
    user_id        uuid NOT NULL REFERENCES accounts (user_id),
    sanction_code  text NOT NULL,
    record_id      uuid NOT NULL REFERENCES sanction_records (record_id),
    PRIMARY KEY (user_id, sanction_code)
);

CREATE INDEX sanction_active_code_idx ON sanction_active (sanction_code);

CREATE TABLE limit_records (
    record_id              uuid        PRIMARY KEY,
    user_id                uuid        NOT NULL REFERENCES accounts (user_id),
    limit_code             text        NOT NULL,
    value                  integer     NOT NULL,
    reason_code            text        NOT NULL,
    actor_type             text        NOT NULL,
    actor_user_id          uuid,
    actor_username         text,
    applied_at             timestamptz NOT NULL DEFAULT now(),
    expires_at             timestamptz,
    removed_at             timestamptz,
    removed_by_type        text,
    removed_by_user_id     uuid,
    removed_by_username    text,
    removed_reason_code    text,
    CONSTRAINT limit_records_actor_chk
        CHECK (actor_user_id IS NULL OR actor_username IS NULL),
    CONSTRAINT limit_records_removed_by_chk
        CHECK (removed_by_user_id IS NULL OR removed_by_username IS NULL)
);

CREATE INDEX limit_records_user_idx
    ON limit_records (user_id, applied_at DESC);

-- limit_active mirrors sanction_active for user-specific limits. value is
-- denormalised so the admin listing predicate can read it without joining
-- the full record history.
CREATE TABLE limit_active (
    user_id     uuid    NOT NULL REFERENCES accounts (user_id),
    limit_code  text    NOT NULL,
    record_id   uuid    NOT NULL REFERENCES limit_records (record_id),
    value       integer NOT NULL,
    PRIMARY KEY (user_id, limit_code)
);

CREATE INDEX limit_active_code_idx ON limit_active (limit_code);

-- =====================================================================
-- Admin domain
-- =====================================================================

CREATE TABLE admin_accounts (
    username       text        PRIMARY KEY,
    password_hash  bytea       NOT NULL,
    created_at     timestamptz NOT NULL DEFAULT now(),
    last_used_at   timestamptz,
    disabled_at    timestamptz
);

-- =====================================================================
-- Lobby domain
-- =====================================================================

-- games is the durable record of every platform game session. owner_user_id
-- is nullable because public games are created by admins through the basic-auth
-- surface; the admin identity lives in admin_accounts and does not map to a
-- user_id. The partial owner index covers private games only.
CREATE TABLE games (
    game_id                uuid        PRIMARY KEY,
    owner_user_id          uuid,
    visibility             text        NOT NULL,
    status                 text        NOT NULL,
    game_name              text        NOT NULL,
    description            text        NOT NULL DEFAULT '',
    min_players            integer     NOT NULL,
    max_players            integer     NOT NULL,
    start_gap_hours        integer     NOT NULL,
    start_gap_players      integer     NOT NULL,
    enrollment_ends_at     timestamptz NOT NULL,
    turn_schedule          text        NOT NULL,
    target_engine_version  text        NOT NULL,
    runtime_snapshot       jsonb       NOT NULL DEFAULT '{}'::jsonb,
    runtime_binding        jsonb,
    created_at             timestamptz NOT NULL DEFAULT now(),
    updated_at             timestamptz NOT NULL DEFAULT now(),
    started_at             timestamptz,
    finished_at            timestamptz,
    CONSTRAINT games_visibility_chk
        CHECK (visibility IN ('public', 'private')),
    CONSTRAINT games_status_chk
        CHECK (status IN (
            'draft', 'enrollment_open', 'ready_to_start', 'starting',
            'start_failed', 'running', 'paused', 'finished', 'cancelled'
        ))
);

CREATE INDEX games_status_created_idx
    ON games (status, created_at DESC, game_id DESC);

CREATE INDEX games_owner_idx
    ON games (owner_user_id)
    WHERE visibility = 'private';

-- applications carries one row per public-game enrollment request. The
-- partial UNIQUE on (applicant_user_id, game_id) WHERE status <> 'rejected'
-- enforces the single-active constraint at the database level. Rejected
-- applications are kept (one applicant may produce multiple rejected rows
-- before submitting a successful one).
CREATE TABLE applications (
    application_id     uuid        PRIMARY KEY,
    game_id            uuid        NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    applicant_user_id  uuid        NOT NULL,
    race_name          text        NOT NULL,
    status             text        NOT NULL,
    created_at         timestamptz NOT NULL DEFAULT now(),
    decided_at         timestamptz,
    CONSTRAINT applications_status_chk
        CHECK (status IN ('pending', 'approved', 'rejected'))
);

CREATE INDEX applications_game_idx ON applications (game_id);
CREATE INDEX applications_user_idx ON applications (applicant_user_id);

CREATE UNIQUE INDEX applications_active_per_user_game_uidx
    ON applications (applicant_user_id, game_id)
    WHERE status <> 'rejected';

-- invites carries one row per private-game invitation. invited_user_id is
-- nullable so the invite-by-code variant (anonymous redemption) sits on the
-- same table. code is unique only when set so user-bound invites without a
-- redemption code coexist freely.
CREATE TABLE invites (
    invite_id        uuid        PRIMARY KEY,
    game_id          uuid        NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    inviter_user_id  uuid        NOT NULL,
    invited_user_id  uuid,
    code             text,
    status           text        NOT NULL,
    race_name        text        NOT NULL DEFAULT '',
    created_at       timestamptz NOT NULL DEFAULT now(),
    expires_at       timestamptz NOT NULL,
    decided_at       timestamptz,
    CONSTRAINT invites_status_chk
        CHECK (status IN ('pending', 'redeemed', 'declined', 'revoked', 'expired'))
);

CREATE INDEX invites_game_idx ON invites (game_id);

CREATE INDEX invites_invited_idx
    ON invites (invited_user_id)
    WHERE invited_user_id IS NOT NULL;

CREATE INDEX invites_inviter_idx        ON invites (inviter_user_id);
CREATE INDEX invites_status_expires_idx ON invites (status, expires_at);

CREATE UNIQUE INDEX invites_code_uidx
    ON invites (code)
    WHERE code IS NOT NULL;

-- memberships carries one row per platform roster entry. Both race_name
-- (original casing) and canonical_key are stored explicitly so downstream
-- readers do not re-derive the canonical form from race_name. Race-name
-- uniqueness across the platform is enforced by race_names below.
CREATE TABLE memberships (
    membership_id  uuid        PRIMARY KEY,
    game_id        uuid        NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    user_id        uuid        NOT NULL,
    race_name      text        NOT NULL,
    canonical_key  text        NOT NULL,
    status         text        NOT NULL,
    joined_at      timestamptz NOT NULL DEFAULT now(),
    removed_at     timestamptz,
    CONSTRAINT memberships_game_user_unique UNIQUE (game_id, user_id),
    CONSTRAINT memberships_status_chk
        CHECK (status IN ('active', 'removed', 'blocked'))
);

CREATE INDEX memberships_game_idx ON memberships (game_id);
CREATE INDEX memberships_user_idx ON memberships (user_id);

-- race_names is the durable Race Name Directory store. One row covers one
-- of three bindings on a canonical key: a registered name (one per
-- canonical, immutable holder), a per-game reservation, or a
-- pending_registration that is waiting on lobby.race_name.register inside
-- the eligible window. The composite primary key (canonical, game_id) lets
-- the same user hold reservations for the same race name across multiple
-- active games concurrently. Registered rows store the all-zero sentinel
-- in game_id so the per-canonical uniqueness rule expresses cleanly as a
-- partial UNIQUE index. Cross-user uniqueness across reservation /
-- pending_registration is enforced by the lobby module on the write path.
CREATE TABLE race_names (
    name             text        NOT NULL,
    canonical        text        NOT NULL,
    status           text        NOT NULL,
    owner_user_id    uuid        NOT NULL,
    game_id          uuid        NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
    source_game_id   uuid,
    reserved_at      timestamptz,
    expires_at       timestamptz,
    registered_at    timestamptz,
    PRIMARY KEY (canonical, game_id),
    CONSTRAINT race_names_status_chk
        CHECK (status IN ('registered', 'reservation', 'pending_registration'))
);

CREATE UNIQUE INDEX race_names_registered_uidx
    ON race_names (canonical)
    WHERE status = 'registered';

CREATE INDEX race_names_owner_idx
    ON race_names (owner_user_id, status);

CREATE INDEX race_names_pending_eligible_idx
    ON race_names (expires_at)
    WHERE status = 'pending_registration';

-- =====================================================================
-- Runtime domain
-- =====================================================================

-- runtime_records consolidates the previous gamemaster.runtime_records and
-- rtmanager.runtime_records into a single row per game. The status enum
-- covers both the engine-state machine (starting, running,
-- generation_in_progress, generation_failed, stopped, engine_unreachable,
-- finished) and the container-state escape hatch (removed) used by
-- reconciliation when the recorded container has disappeared.
CREATE TABLE runtime_records (
    game_id                  uuid        PRIMARY KEY REFERENCES games (game_id) ON DELETE CASCADE,
    status                   text        NOT NULL,
    current_container_id     text,
    current_image_ref        text,
    current_engine_version   text,
    engine_endpoint          text        NOT NULL,
    state_path               text,
    docker_network           text,
    turn_schedule            text        NOT NULL,
    current_turn             integer     NOT NULL DEFAULT 0,
    next_generation_at       timestamptz,
    skip_next_tick           boolean     NOT NULL DEFAULT false,
    paused                   boolean     NOT NULL DEFAULT false,
    paused_at                timestamptz,
    engine_health            text        NOT NULL DEFAULT '',
    created_at               timestamptz NOT NULL DEFAULT now(),
    updated_at               timestamptz NOT NULL DEFAULT now(),
    started_at               timestamptz,
    stopped_at               timestamptz,
    finished_at              timestamptz,
    removed_at               timestamptz,
    last_observed_at         timestamptz,
    CONSTRAINT runtime_records_status_chk
        CHECK (status IN (
            'starting', 'running', 'generation_in_progress',
            'generation_failed', 'stopped', 'engine_unreachable',
            'finished', 'removed'
        ))
);

CREATE INDEX runtime_records_status_next_gen_idx
    ON runtime_records (status, next_generation_at);

CREATE TABLE engine_versions (
    version     text        PRIMARY KEY,
    image_ref   text        NOT NULL,
    enabled     boolean     NOT NULL DEFAULT true,
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now()
);

-- player_mappings carries the (game_id, user_id) → (race_name,
-- engine_player_uuid) projection installed at register-runtime. The
-- composite primary key serves both the per-request lookup and the per-game
-- roster reads. The partial UNIQUE on (game_id, race_name) enforces the
-- one-race-per-game invariant at the storage boundary.
CREATE TABLE player_mappings (
    game_id             uuid        NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    user_id             uuid        NOT NULL,
    race_name           text        NOT NULL,
    engine_player_uuid  uuid        NOT NULL,
    created_at          timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (game_id, user_id)
);

CREATE UNIQUE INDEX player_mappings_game_race_uidx
    ON player_mappings (game_id, race_name);

CREATE TABLE runtime_operation_log (
    operation_id   uuid        PRIMARY KEY,
    game_id        uuid        NOT NULL,
    op             text        NOT NULL,
    source         text        NOT NULL,
    status         text        NOT NULL,
    image_ref      text        NOT NULL DEFAULT '',
    container_id   text        NOT NULL DEFAULT '',
    error_code     text        NOT NULL DEFAULT '',
    error_message  text        NOT NULL DEFAULT '',
    started_at     timestamptz NOT NULL DEFAULT now(),
    finished_at    timestamptz
);

CREATE INDEX runtime_operation_log_game_started_idx
    ON runtime_operation_log (game_id, started_at DESC);

-- runtime_health_snapshots records every health observation per game.
-- Multiple rows per game are expected; readers consume the latest by
-- observed_at.
CREATE TABLE runtime_health_snapshots (
    snapshot_id  uuid        PRIMARY KEY,
    game_id      uuid        NOT NULL,
    observed_at  timestamptz NOT NULL DEFAULT now(),
    payload      jsonb       NOT NULL
);

CREATE INDEX runtime_health_snapshots_game_idx
    ON runtime_health_snapshots (game_id, observed_at DESC);

-- =====================================================================
-- Mail outbox domain
-- =====================================================================

CREATE TABLE mail_payloads (
    payload_id    uuid        PRIMARY KEY,
    content_type  text        NOT NULL,
    subject       text,
    body          bytea       NOT NULL,
    created_at    timestamptz NOT NULL DEFAULT now()
);

-- mail_deliveries holds one durable record per accepted logical mail
-- delivery. The (template_id, idempotency_key) UNIQUE constraint is the
-- idempotency reservation. next_attempt_at drives the worker's
-- FOR UPDATE SKIP LOCKED pickup; the partial index keeps the scan tight
-- because rows in terminal status do not carry next_attempt_at.
CREATE TABLE mail_deliveries (
    delivery_id        uuid        PRIMARY KEY,
    template_id        text        NOT NULL,
    idempotency_key    text        NOT NULL,
    status             text        NOT NULL,
    attempts           integer     NOT NULL DEFAULT 0,
    next_attempt_at    timestamptz,
    payload_id         uuid        NOT NULL REFERENCES mail_payloads (payload_id),
    last_error         text        NOT NULL DEFAULT '',
    created_at         timestamptz NOT NULL DEFAULT now(),
    updated_at         timestamptz NOT NULL DEFAULT now(),
    sent_at            timestamptz,
    dead_lettered_at   timestamptz,
    CONSTRAINT mail_deliveries_idempotency_unique
        UNIQUE (template_id, idempotency_key),
    CONSTRAINT mail_deliveries_status_chk
        CHECK (status IN ('pending', 'retrying', 'sent', 'dead_lettered'))
);

CREATE INDEX mail_deliveries_due_idx
    ON mail_deliveries (next_attempt_at)
    WHERE next_attempt_at IS NOT NULL;

CREATE INDEX mail_deliveries_status_idx ON mail_deliveries (status);

CREATE TABLE mail_recipients (
    recipient_id  uuid PRIMARY KEY,
    delivery_id   uuid NOT NULL REFERENCES mail_deliveries (delivery_id) ON DELETE CASCADE,
    address       text NOT NULL,
    kind          text NOT NULL,
    CONSTRAINT mail_recipients_kind_chk
        CHECK (kind IN ('to', 'cc', 'bcc', 'reply_to'))
);

CREATE INDEX mail_recipients_delivery_idx ON mail_recipients (delivery_id);

CREATE TABLE mail_attempts (
    attempt_id   uuid        PRIMARY KEY,
    delivery_id  uuid        NOT NULL REFERENCES mail_deliveries (delivery_id) ON DELETE CASCADE,
    attempt_no   integer     NOT NULL,
    started_at   timestamptz NOT NULL DEFAULT now(),
    finished_at  timestamptz,
    outcome      text        NOT NULL,
    error        text        NOT NULL DEFAULT '',
    CONSTRAINT mail_attempts_delivery_attempt_unique
        UNIQUE (delivery_id, attempt_no),
    CONSTRAINT mail_attempts_outcome_chk
        CHECK (outcome IN ('success', 'transient_error', 'permanent_error'))
);

CREATE TABLE mail_dead_letters (
    dead_letter_id  uuid        PRIMARY KEY,
    delivery_id     uuid        NOT NULL REFERENCES mail_deliveries (delivery_id) ON DELETE CASCADE,
    archived_at     timestamptz NOT NULL DEFAULT now(),
    reason          text        NOT NULL
);

CREATE INDEX mail_dead_letters_listing_idx
    ON mail_dead_letters (archived_at DESC);

-- =====================================================================
-- Notification domain
-- =====================================================================

CREATE TABLE notifications (
    notification_id  uuid        PRIMARY KEY,
    kind             text        NOT NULL,
    idempotency_key  text        NOT NULL,
    user_id          uuid,
    payload          jsonb,
    created_at       timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT notifications_idempotency_unique
        UNIQUE (kind, idempotency_key),
    CONSTRAINT notifications_kind_chk
        CHECK (kind IN (
            'lobby.invite.received', 'lobby.invite.revoked',
            'lobby.application.submitted', 'lobby.application.approved',
            'lobby.application.rejected',
            'lobby.membership.removed', 'lobby.membership.blocked',
            'lobby.race_name.registered', 'lobby.race_name.pending',
            'lobby.race_name.expired',
            'runtime.image_pull_failed', 'runtime.container_start_failed',
            'runtime.start_config_invalid',
            'game.turn.ready', 'game.paused',
            'diplomail.message.received'
        ))
);

CREATE INDEX notifications_listing_idx
    ON notifications (created_at DESC, notification_id DESC);

CREATE TABLE notification_routes (
    route_id          uuid        PRIMARY KEY,
    notification_id   uuid        NOT NULL REFERENCES notifications (notification_id) ON DELETE CASCADE,
    channel           text        NOT NULL,
    status            text        NOT NULL,
    attempts          integer     NOT NULL DEFAULT 0,
    max_attempts      integer     NOT NULL,
    next_attempt_at   timestamptz,
    last_attempt_at   timestamptz,
    last_error        text        NOT NULL DEFAULT '',
    resolved_email    text        NOT NULL DEFAULT '',
    resolved_locale   text        NOT NULL DEFAULT '',
    created_at        timestamptz NOT NULL DEFAULT now(),
    updated_at        timestamptz NOT NULL DEFAULT now(),
    published_at      timestamptz,
    dead_lettered_at  timestamptz,
    skipped_at        timestamptz,
    CONSTRAINT notification_routes_channel_chk
        CHECK (channel IN ('push', 'email')),
    CONSTRAINT notification_routes_status_chk
        CHECK (status IN ('pending', 'retrying', 'published', 'skipped', 'dead_lettered'))
);

CREATE INDEX notification_routes_due_idx
    ON notification_routes (next_attempt_at)
    WHERE next_attempt_at IS NOT NULL;

CREATE INDEX notification_routes_status_idx       ON notification_routes (status);
CREATE INDEX notification_routes_channel_idx      ON notification_routes (channel);
CREATE INDEX notification_routes_notification_idx ON notification_routes (notification_id);

CREATE TABLE notification_dead_letters (
    dead_letter_id   uuid        PRIMARY KEY,
    notification_id  uuid        NOT NULL REFERENCES notifications (notification_id) ON DELETE CASCADE,
    route_id         uuid        NOT NULL,
    archived_at      timestamptz NOT NULL DEFAULT now(),
    reason           text        NOT NULL
);

CREATE TABLE notification_malformed_intents (
    id           uuid        PRIMARY KEY,
    received_at  timestamptz NOT NULL DEFAULT now(),
    payload      jsonb       NOT NULL,
    reason       text        NOT NULL
);

CREATE INDEX notification_malformed_intents_listing_idx
    ON notification_malformed_intents (received_at DESC);

-- =====================================================================
-- Diplomail domain
-- =====================================================================

-- diplomail_messages is the canonical record of every diplomatic-mail
-- send: one row per personal message, owner/admin send, broadcast, or
-- system notification. game_name is captured at insert time so the
-- bulk-purge / rename paths still render correctly. sender_username
-- carries either accounts.user_name (sender_kind='player') or
-- admin_accounts.username (sender_kind='admin'); system senders leave
-- it NULL. body and subject are plain UTF-8; length limits are enforced
-- in the service layer and may be tuned without a migration.
CREATE TABLE diplomail_messages (
    message_id        uuid        PRIMARY KEY,
    game_id           uuid        NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
    game_name         text        NOT NULL,
    kind              text        NOT NULL,
    sender_kind       text        NOT NULL,
    sender_user_id    uuid,
    sender_username   text,
    sender_ip         text        NOT NULL DEFAULT '',
    subject           text        NOT NULL DEFAULT '',
    body              text        NOT NULL,
    body_lang         text        NOT NULL DEFAULT 'und',
    broadcast_scope   text        NOT NULL DEFAULT 'single',
    created_at        timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT diplomail_messages_kind_chk
        CHECK (kind IN ('personal', 'admin')),
    CONSTRAINT diplomail_messages_sender_kind_chk
        CHECK (sender_kind IN ('player', 'admin', 'system')),
    CONSTRAINT diplomail_messages_sender_identity_chk CHECK (
        (sender_kind = 'player' AND sender_user_id IS NOT NULL AND sender_username IS NOT NULL) OR
        (sender_kind = 'admin'  AND sender_user_id IS NULL     AND sender_username IS NOT NULL) OR
        (sender_kind = 'system' AND sender_user_id IS NULL     AND sender_username IS NULL)
    ),
    CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
        (kind = 'personal' AND sender_kind = 'player') OR
        (kind = 'admin'    AND sender_kind IN ('player', 'admin', 'system'))
    ),
    CONSTRAINT diplomail_messages_broadcast_scope_chk
        CHECK (broadcast_scope IN ('single', 'game_broadcast', 'multi_game_broadcast'))
);

CREATE INDEX diplomail_messages_game_idx
    ON diplomail_messages (game_id, created_at DESC);

CREATE INDEX diplomail_messages_sender_user_idx
    ON diplomail_messages (sender_user_id, created_at DESC)
    WHERE sender_user_id IS NOT NULL;

-- diplomail_recipients carries one row per (message, recipient). The
-- per-user read/delete/deliver/notified state lives here. recipient
-- snapshots (user_name, race_name) are captured at insert time so the
-- inbox listing and admin search render without joining accounts /
-- memberships and survive race-name renames, membership revocation,
-- and account soft-delete. recipient_race_name is nullable for the
-- rare admin notifications addressed to a player who no longer has an
-- active membership in the game.
CREATE TABLE diplomail_recipients (
    recipient_id                  uuid        PRIMARY KEY,
    message_id                    uuid        NOT NULL REFERENCES diplomail_messages (message_id) ON DELETE CASCADE,
    game_id                       uuid        NOT NULL,
    user_id                       uuid        NOT NULL,
    recipient_user_name           text        NOT NULL,
    recipient_race_name           text,
    recipient_preferred_language  text        NOT NULL DEFAULT '',
    available_at                  timestamptz,
    translation_attempts          integer     NOT NULL DEFAULT 0,
    next_translation_attempt_at   timestamptz,
    delivered_at                  timestamptz,
    read_at                       timestamptz,
    deleted_at                    timestamptz,
    notified_at                   timestamptz,
    CONSTRAINT diplomail_recipients_unique UNIQUE (message_id, user_id)
);

CREATE INDEX diplomail_recipients_inbox_idx
    ON diplomail_recipients (user_id, game_id, deleted_at, read_at);

CREATE INDEX diplomail_recipients_unread_idx
    ON diplomail_recipients (user_id, game_id)
    WHERE read_at IS NULL AND deleted_at IS NULL AND available_at IS NOT NULL;

-- Index drives the translation worker's pending-pair pickup. The
-- partial filter keeps the scan tight: terminal-state recipients
-- (with a non-NULL available_at) never appear in this btree. The
-- composite ordering puts the next-attempt clock first so the
-- backoff filter (`next_translation_attempt_at <= now()`) seeks
-- before the secondary cluster on (message_id, lang).
CREATE INDEX diplomail_recipients_pending_translation_idx
    ON diplomail_recipients (next_translation_attempt_at, message_id, recipient_preferred_language)
    WHERE available_at IS NULL;

-- diplomail_translations caches one rendered translation per
-- (message, target_lang) so a broadcast addressed to many recipients
-- with the same preferred_language is translated once. translator
-- identifies the backend that produced the row.
CREATE TABLE diplomail_translations (
    translation_id      uuid        PRIMARY KEY,
    message_id          uuid        NOT NULL REFERENCES diplomail_messages (message_id) ON DELETE CASCADE,
    target_lang         text        NOT NULL,
    translated_subject  text        NOT NULL DEFAULT '',
    translated_body     text        NOT NULL,
    translator          text        NOT NULL,
    translated_at       timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT diplomail_translations_unique UNIQUE (message_id, target_lang)
);

-- =====================================================================
-- Geo domain
-- =====================================================================

CREATE TABLE user_country_counters (
    user_id       uuid    NOT NULL,
    country       text    NOT NULL,
    count         bigint  NOT NULL DEFAULT 0,
    last_seen_at  timestamptz,
    PRIMARY KEY (user_id, country)
);

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