-- Gateway sessions: one row per signed-in TV.
--
-- token_hash is SHA-256 of the bearer token handed to the device, so a database dump
-- does not hand over working gateway tokens. emby_token IS the live upstream token and
-- is stored as-is: treat this volume as a secret store.
CREATE TABLE IF NOT EXISTS sessions (
    token_hash   BYTEA PRIMARY KEY,
    emby_user_id TEXT        NOT NULL,
    emby_token   TEXT        NOT NULL,
    username     TEXT        NOT NULL,
    server_id    TEXT        NOT NULL DEFAULT '',
    device_id    TEXT        NOT NULL DEFAULT '',
    device_name  TEXT        NOT NULL DEFAULT 'Memby TV',
    client_version  TEXT     NOT NULL DEFAULT '',
    client_protocol TEXT     NOT NULL DEFAULT '',
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_version TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_protocol TEXT NOT NULL DEFAULT '';

-- Older builds could create more than one token for the same physical TV. Keep the most
-- recently used row before adding the identity constraint.
DELETE FROM sessions older
USING sessions newer
WHERE older.emby_user_id = newer.emby_user_id
  AND older.device_id = newer.device_id
  AND (
      older.last_seen_at < newer.last_seen_at
      OR (older.last_seen_at = newer.last_seen_at AND older.token_hash < newer.token_hash)
  );

CREATE INDEX IF NOT EXISTS sessions_emby_user_idx ON sessions (emby_user_id);
CREATE INDEX IF NOT EXISTS sessions_last_seen_idx ON sessions (last_seen_at);
CREATE UNIQUE INDEX IF NOT EXISTS sessions_user_device_idx
    ON sessions (emby_user_id, device_id);

-- The imported library.
--
-- payload is Emby's item JSON verbatim, so rows served from here are byte-identical to
-- rows served live. Deliberately holds NO per-user state: everything is imported with
-- EnableUserData=false, because one household shares this table and watched/favourite
-- flags are not shareable. Anything user-specific still comes from Emby live.
CREATE TABLE IF NOT EXISTS library_items (
    id               TEXT PRIMARY KEY,
    type             TEXT        NOT NULL DEFAULT '',
    name             TEXT        NOT NULL DEFAULT '',
    series_id        TEXT        NOT NULL DEFAULT '',
    series_name      TEXT        NOT NULL DEFAULT '',
    production_year  INT,
    community_rating REAL,
    genres           TEXT[]      NOT NULL DEFAULT '{}',
    studios          TEXT[]      NOT NULL DEFAULT '{}',
    date_created     TIMESTAMPTZ,
    search_text      TEXT        NOT NULL DEFAULT '',
    payload          JSONB       NOT NULL,
    synced_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- 'simple' rather than 'english': film titles are proper nouns, and stemming
    -- "Arrival" into "arriv" helps nobody.
    search_tsv       tsvector GENERATED ALWAYS AS (to_tsvector('simple', search_text)) STORED
);

CREATE INDEX IF NOT EXISTS library_items_search_idx ON library_items USING GIN (search_tsv);
CREATE INDEX IF NOT EXISTS library_items_genres_idx ON library_items USING GIN (genres);
CREATE INDEX IF NOT EXISTS library_items_type_created_idx ON library_items (type, date_created DESC);
CREATE INDEX IF NOT EXISTS library_items_synced_idx ON library_items (synced_at);

-- One row per import, so the admin page can show what happened and when.
CREATE TABLE IF NOT EXISTS sync_runs (
    id             BIGSERIAL PRIMARY KEY,
    kind           TEXT        NOT NULL,                    -- full | incremental
    trigger        TEXT        NOT NULL DEFAULT 'schedule', -- schedule | manual | startup
    status         TEXT        NOT NULL,                    -- running | success | failed
    started_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    finished_at    TIMESTAMPTZ,
    items_seen     INT         NOT NULL DEFAULT 0,
    items_upserted INT         NOT NULL DEFAULT 0,
    items_removed  INT         NOT NULL DEFAULT 0,
    error          TEXT        NOT NULL DEFAULT ''
);

CREATE INDEX IF NOT EXISTS sync_runs_started_idx ON sync_runs (started_at DESC);

-- Small key/value store for operator switches (currently just maintenance mode). Kept in
-- Postgres rather than memory so a restart cannot silently bring the app back up.
CREATE TABLE IF NOT EXISTS app_settings (
    key        TEXT PRIMARY KEY,
    value      JSONB       NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Row-level engagement. One row per reported event; aggregation happens at read time,
-- which is fine at household scale and keeps the write path trivial.
CREATE TABLE IF NOT EXISTS row_events (
    id           BIGSERIAL PRIMARY KEY,
    occurred_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    emby_user_id TEXT        NOT NULL,
    row_id       TEXT        NOT NULL,
    row_kind     TEXT        NOT NULL DEFAULT '',
    event        TEXT        NOT NULL,                -- impression | focus | select
    item_id      TEXT        NOT NULL DEFAULT '',
    dwell_ms     INT         NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);

-- Search terms are retained separately from row engagement so they can inform future
-- ranking/recommendation work without coupling that analysis to rendered rows.
CREATE TABLE IF NOT EXISTS search_history (
    id           BIGSERIAL PRIMARY KEY,
    occurred_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    emby_user_id TEXT        NOT NULL,
    query        TEXT        NOT NULL
);

CREATE INDEX IF NOT EXISTS search_history_user_time_idx
    ON search_history (emby_user_id, occurred_at DESC);

-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
CREATE TABLE IF NOT EXISTS tracearr_sessions (
    server_id               TEXT        NOT NULL DEFAULT '',
    tracearr_session_id     TEXT        NOT NULL,
    tracearr_user_id        TEXT        NOT NULL DEFAULT '',
    username                TEXT        NOT NULL DEFAULT '',
    state                   TEXT        NOT NULL DEFAULT '',
    media_type              TEXT        NOT NULL DEFAULT '',
    media_title             TEXT        NOT NULL DEFAULT '',
    show_title              TEXT        NOT NULL DEFAULT '',
    season_number           INT,
    episode_number          INT,
    production_year         INT,
    started_at              TIMESTAMPTZ,
    stopped_at              TIMESTAMPTZ,
    duration_ms             BIGINT      NOT NULL DEFAULT 0,
    progress_ms             BIGINT      NOT NULL DEFAULT 0,
    total_duration_ms       BIGINT      NOT NULL DEFAULT 0,
    watched                 BOOLEAN     NOT NULL DEFAULT false,
    device                  TEXT        NOT NULL DEFAULT '',
    player                  TEXT        NOT NULL DEFAULT '',
    product                 TEXT        NOT NULL DEFAULT '',
    platform                TEXT        NOT NULL DEFAULT '',
    is_transcode            BOOLEAN     NOT NULL DEFAULT false,
    video_decision          TEXT        NOT NULL DEFAULT '',
    audio_decision          TEXT        NOT NULL DEFAULT '',
    source_video_codec      TEXT        NOT NULL DEFAULT '',
    source_audio_codec      TEXT        NOT NULL DEFAULT '',
    emby_item_id            TEXT        NOT NULL DEFAULT '',
    emby_series_id          TEXT        NOT NULL DEFAULT '',
    source_fingerprint      BYTEA       NOT NULL,
    source_seen_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
    imported_at             TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (server_id, tracearr_session_id)
);

CREATE INDEX IF NOT EXISTS tracearr_sessions_user_time_idx
    ON tracearr_sessions (tracearr_user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS tracearr_sessions_username_time_idx
    ON tracearr_sessions (lower(username), started_at DESC);
CREATE INDEX IF NOT EXISTS tracearr_sessions_emby_item_idx
    ON tracearr_sessions (emby_item_id) WHERE emby_item_id <> '';
CREATE INDEX IF NOT EXISTS tracearr_sessions_emby_series_idx
    ON tracearr_sessions (emby_series_id) WHERE emby_series_id <> '';

-- One compact derived profile per Emby user. Variable affinity maps stay together as
-- JSON because the builder reads and replaces the whole profile; no request filters
-- inside these maps.
CREATE TABLE IF NOT EXISTS recommendation_user_profiles (
    emby_user_id             TEXT        PRIMARY KEY,
    tracearr_user_id         TEXT        NOT NULL DEFAULT '',
    tracearr_username        TEXT        NOT NULL DEFAULT '',
    source_session_count     INT         NOT NULL DEFAULT 0,
    mean_completion_ratio    REAL        NOT NULL DEFAULT 0,
    typical_session_minutes  INT         NOT NULL DEFAULT 0,
    genre_affinity           JSONB       NOT NULL DEFAULT '{}'::jsonb,
    title_affinity           JSONB       NOT NULL DEFAULT '{}'::jsonb,
    studio_affinity          JSONB       NOT NULL DEFAULT '{}'::jsonb,
    codec_outcomes           JSONB       NOT NULL DEFAULT '{}'::jsonb,
    signals_through          TIMESTAMPTZ,
    built_at                 TIMESTAMPTZ,
    pool_built_at            TIMESTAMPTZ,
    dirty_since              TIMESTAMPTZ DEFAULT now(),
    last_error               TEXT        NOT NULL DEFAULT ''
);

CREATE INDEX IF NOT EXISTS recommendation_profiles_dirty_idx
    ON recommendation_user_profiles (dirty_since)
    WHERE dirty_since IS NOT NULL;

-- Every eligible ranked title is retained. At household scale this is only tens of
-- thousands of compact rows and gives short runtime filters far more headroom than the
-- old 240-title request pool.
CREATE TABLE IF NOT EXISTS for_you_candidates (
    emby_user_id             TEXT        NOT NULL,
    item_id                  TEXT        NOT NULL,
    base_rank                INT         NOT NULL,
    base_score               REAL        NOT NULL DEFAULT 0,
    runtime_minutes          INT         NOT NULL DEFAULT 0,
    affinity_score           REAL        NOT NULL DEFAULT 0,
    compatibility_score      REAL        NOT NULL DEFAULT 0,
    compatibility_label      TEXT        NOT NULL DEFAULT '',
    reason_kind              TEXT        NOT NULL DEFAULT '',
    reason_genre             TEXT        NOT NULL DEFAULT '',
    reason_source_session_id TEXT        NOT NULL DEFAULT '',
    reason_source_item_id    TEXT        NOT NULL DEFAULT '',
    reason_source_title      TEXT        NOT NULL DEFAULT '',
    recommendation_reason    TEXT        NOT NULL DEFAULT '',
    built_at                 TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (emby_user_id, item_id),
    FOREIGN KEY (item_id) REFERENCES library_items(id) ON DELETE CASCADE
);

CREATE UNIQUE INDEX IF NOT EXISTS for_you_candidates_user_rank_idx
    ON for_you_candidates (emby_user_id, base_rank);
CREATE INDEX IF NOT EXISTS for_you_candidates_user_runtime_rank_idx
    ON for_you_candidates (emby_user_id, runtime_minutes, base_rank);
