-- learning-service: первая миграция. Создаёт схему MVP:
--   тесты + вопросы + ответы + попытки прохождения
--   курсы + уроки (видео + markdown + опциональный тест в конце)
--   access_grants — гранулярные доступы (user / role / department / position)
--   public_tokens — одноразовые ссылки для кандидатов с email-проверкой
--
-- Cross-service ссылки (subject_id, candidate_id, owner_user_id) хранятся
-- как UUID без FK — portal/candidates живут в других БД. Целостность
-- гарантируется бизнес-логикой (валидация при создании).
--
-- 1 файл вместо разнесения по фичам — на старте проще читать как единое
-- целое; начнётся эволюция — разнесём.

BEGIN;

-- ============================================================
-- TESTS
-- ============================================================
-- Тест — самостоятельная единица для прохождения. Может быть прикреплён
-- к уроку курса (lessons.test_id) или существовать отдельно (для public-
-- ссылок кандидатам, ad-hoc проверок и т.п.).
CREATE TABLE tests (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title           TEXT NOT NULL,
    description     TEXT NOT NULL DEFAULT '',
    -- passing_score: процент правильных от max, 0..100. 70 = по умолчанию
    -- классическая «3+». Если null — авто-зачёт не рассчитываем,
    -- HR смотрит результат вручную.
    passing_score   INT NULL CHECK (passing_score IS NULL OR (passing_score >= 0 AND passing_score <= 100)),
    -- max_attempts: 0 = без лимита, N > 0 — попытки ограничены.
    max_attempts    INT NOT NULL DEFAULT 0 CHECK (max_attempts >= 0),
    -- time_limit_sec: 0 = без таймера; иначе UI ставит обратный отсчёт.
    time_limit_sec  INT NOT NULL DEFAULT 0 CHECK (time_limit_sec >= 0),
    -- shuffle_questions: если true, UI каждой попытке отдаёт вопросы в
    -- случайном порядке. Серверный select по test_questions.position
    -- остаётся; шафлинг на клиенте.
    shuffle_questions BOOLEAN NOT NULL DEFAULT FALSE,
    -- show_correct_after: показывать правильные ответы по завершении
    -- (учебные тесты — да; аттестационные — обычно нет).
    show_correct_after BOOLEAN NOT NULL DEFAULT TRUE,
    -- is_published — черновики не видны никому кроме owner; published
    -- видны всем, кому есть access_grant.
    is_published    BOOLEAN NOT NULL DEFAULT FALSE,
    owner_user_id   UUID NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tests_owner     ON tests(owner_user_id);
CREATE INDEX idx_tests_published ON tests(is_published) WHERE is_published = TRUE;

-- Вопрос теста. kind определяет UI-рендер и логику авто-оценки.
--   single — radio-buttons, ровно один is_correct;
--   multi  — checkboxes, может быть >=1 is_correct;
--   text   — свободный ввод, оценка вручную HR'ом (auto_grade=false).
CREATE TABLE test_questions (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    test_id     UUID NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
    -- position: явный порядок (drag&drop в UI выставит). Уникальность
    -- НЕ обеспечиваем (двигание через UPDATE батчем — было бы много
    -- констрейнт-flip'ов; на дубли клиент не натолкнётся в норме).
    position    INT NOT NULL DEFAULT 0,
    kind        TEXT NOT NULL DEFAULT 'single'
                CHECK (kind IN ('single','multi','text')),
    -- text — текст вопроса; markdown допустим, фронт рендерит.
    text        TEXT NOT NULL,
    -- points: вклад в общий счёт. 1 по умолчанию; для «бонусных» можно 2-3.
    points      INT NOT NULL DEFAULT 1 CHECK (points > 0),
    -- explanation: показывается после ответа (если show_correct_after).
    explanation TEXT NOT NULL DEFAULT '',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_test_questions_test ON test_questions(test_id, position);

-- Вариант ответа. Для kind=text — не используется (NULL=correct + sample_answer
-- в самом вопросе). Для single/multi — варианты с is_correct-флагом.
CREATE TABLE test_answers (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    question_id UUID NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE,
    position    INT NOT NULL DEFAULT 0,
    text        TEXT NOT NULL,
    is_correct  BOOLEAN NOT NULL DEFAULT FALSE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_test_answers_question ON test_answers(question_id, position);

-- Попытка прохождения. Один из {user_id, public_token_id} обязателен —
-- либо это сотрудник под портальным логином, либо кандидат по public-токену.
-- candidate_id заполняется когда токен был выпущен под конкретного кандидата
-- (см. public_tokens.candidate_id); удобно для join'ов с candidates-сервисом.
CREATE TABLE test_attempts (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    test_id         UUID NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
    user_id         UUID NULL,
    public_token_id UUID NULL,
    candidate_id    BIGINT NULL,
    status          TEXT NOT NULL DEFAULT 'in_progress'
                    CHECK (status IN ('in_progress','submitted','graded','expired')),
    -- score: суммарные баллы за правильные ответы; max_score = sum points
    -- по test_questions на момент сабмита (фиксируем для read-only результата).
    score           INT NULL,
    max_score       INT NULL,
    -- passed: рассчитывается при сабмите если у теста passing_score != NULL.
    passed          BOOLEAN NULL,
    -- Денормализованный snapshot ФИО респондента для public-ссылок:
    -- кандидат заполняет в форме перед стартом, мы сохраняем, чтобы HR видел
    -- кто проходил (без join'а с candidates).
    respondent_name  TEXT NOT NULL DEFAULT '',
    respondent_email TEXT NOT NULL DEFAULT '',
    started_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    submitted_at     TIMESTAMPTZ NULL,
    graded_at        TIMESTAMPTZ NULL,
    -- Метаданные клиента — для аудита подозрительных попыток (один токен,
    -- разные IP'шники → возможно ссылку перешарили).
    ip               TEXT NOT NULL DEFAULT '',
    user_agent       TEXT NOT NULL DEFAULT '',
    -- Гарантируем что заполнен хотя бы один из user_id / public_token_id.
    CONSTRAINT test_attempts_subject_required CHECK (
        user_id IS NOT NULL OR public_token_id IS NOT NULL
    )
);
CREATE INDEX idx_test_attempts_test       ON test_attempts(test_id, started_at DESC);
CREATE INDEX idx_test_attempts_user       ON test_attempts(user_id, started_at DESC)
    WHERE user_id IS NOT NULL;
CREATE INDEX idx_test_attempts_candidate  ON test_attempts(candidate_id, started_at DESC)
    WHERE candidate_id IS NOT NULL;
CREATE INDEX idx_test_attempts_token      ON test_attempts(public_token_id, started_at DESC)
    WHERE public_token_id IS NOT NULL;

-- Ответ пользователя на конкретный вопрос в попытке. Хранится JSONB,
-- т.к. формат зависит от kind:
--   single → {"answer_id": "uuid"}
--   multi  → {"answer_ids": ["uuid", "uuid"]}
--   text   → {"text": "...свободный ответ..."}
-- При сабмите attempts-handler сверяется с test_answers.is_correct и
-- считает score; для text — оставляет null до ручной оценки HR'ом.
CREATE TABLE test_attempt_answers (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    attempt_id   UUID NOT NULL REFERENCES test_attempts(id) ON DELETE CASCADE,
    question_id  UUID NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE,
    payload      JSONB NOT NULL,
    -- correct: NULL для text (ждёт ручной оценки), true/false для auto-graded.
    correct      BOOLEAN NULL,
    -- score: 0..question.points. Заполняется при сабмите (auto) или вручную HR'ом.
    score        INT NULL CHECK (score IS NULL OR score >= 0),
    answered_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (attempt_id, question_id)
);

-- ============================================================
-- COURSES
-- ============================================================
-- Курс = упорядоченный список уроков. Уроки могут содержать видео + текст
-- и ОПЦИОНАЛЬНО прикреплённый тест (test_id NULL — урок без проверки).
CREATE TABLE courses (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title           TEXT NOT NULL,
    description     TEXT NOT NULL DEFAULT '',
    -- slug: короткий человекочитаемый идентификатор для URL'ей
    -- (/learning/c/onboarding-2025). Уникален среди опубликованных.
    slug            TEXT NOT NULL,
    -- cover_image_key: опциональная картинка-обложка (MinIO key).
    cover_image_key TEXT NOT NULL DEFAULT '',
    is_published    BOOLEAN NOT NULL DEFAULT FALSE,
    owner_user_id   UUID NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (slug)
);
CREATE INDEX idx_courses_owner     ON courses(owner_user_id);
CREATE INDEX idx_courses_published ON courses(is_published) WHERE is_published = TRUE;

-- Урок. Внутри — markdown-тело (всегда есть, может быть пустым) + видео
-- (опционально, через MinIO key) + тест в конце (опционально, FK на tests).
CREATE TABLE lessons (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    course_id       UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
    position        INT NOT NULL DEFAULT 0,
    title           TEXT NOT NULL,
    -- markdown: основной контент. Можно использовать сам по себе (без видео).
    markdown        TEXT NOT NULL DEFAULT '',
    -- video_key: ключ объекта в MinIO bucket (см. config.MinIOBucket).
    -- При удалении урока физический файл НЕ удаляется — отдельный сборщик
    -- мусора по «висящим» ключам, чтобы случайный delete не уничтожил видео.
    video_key       TEXT NOT NULL DEFAULT '',
    video_duration_sec INT NOT NULL DEFAULT 0,
    -- test_id: связь с тестом. На уровне урока — для проверки усвоения.
    test_id         UUID NULL REFERENCES tests(id) ON DELETE SET NULL,
    -- is_required: курс не считается пройденным пока этот урок не закрыт.
    -- В MVP «закрыт» = выставлен flag в progress-таблице (см. ниже).
    is_required     BOOLEAN NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_lessons_course ON lessons(course_id, position);

-- Прогресс по урокам. Один ряд = один пользователь × один урок.
-- viewed_at = просмотрел видео/прочитал markdown; completed_at = плюс
-- сдал прикреплённый тест (если он был). Для уроков без теста completed_at
-- ставится одновременно с viewed_at (handler сам отметит).
CREATE TABLE lesson_progress (
    lesson_id    UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
    user_id      UUID NOT NULL,
    viewed_at    TIMESTAMPTZ NULL,
    completed_at TIMESTAMPTZ NULL,
    PRIMARY KEY (lesson_id, user_id)
);
CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id, completed_at DESC);

-- ============================================================
-- ACCESS GRANTS
-- ============================================================
-- Гранулярный доступ к ресурсу. Один ряд = «кому что разрешено».
--
-- resource_type: 'test' | 'course'
-- subject_type:  'user' | 'role' | 'department' | 'position' | 'public'
--   - user: subject_id = portal user_id (UUID)
--   - role: subject_id = portal role_id (UUID) — все носители роли получают доступ
--   - department: subject_id = portal department_id (UUID) — все из отдела
--   - position: subject_id = portal position_id (UUID) — все на этой должности
--   - public: subject_id = NULL — anyone с действующим public-токеном
--
-- can_manage: если true — subject может редактировать ресурс (owner + admins
-- получают это автоматически без grant'а; здесь — для «соавторов»).
CREATE TABLE access_grants (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    resource_type TEXT NOT NULL CHECK (resource_type IN ('test','course')),
    resource_id   UUID NOT NULL,
    subject_type  TEXT NOT NULL CHECK (subject_type IN ('user','role','department','position','public')),
    subject_id    UUID NULL,
    can_manage    BOOLEAN NOT NULL DEFAULT FALSE,
    granted_by    UUID NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    -- public: subject_id обязательно NULL; для остальных типов — обязательно заполнено.
    CONSTRAINT access_grants_subject_id_match CHECK (
        (subject_type = 'public' AND subject_id IS NULL) OR
        (subject_type <> 'public' AND subject_id IS NOT NULL)
    ),
    -- Уникальность: не можем выдать два одинаковых грантa.
    UNIQUE (resource_type, resource_id, subject_type, subject_id)
);
CREATE INDEX idx_access_resource ON access_grants(resource_type, resource_id);
CREATE INDEX idx_access_subject  ON access_grants(subject_type, subject_id)
    WHERE subject_id IS NOT NULL;

-- ============================================================
-- PUBLIC TOKENS
-- ============================================================
-- Одноразовая ссылка для кандидата. HR создаёт токен на конкретный тест
-- или курс, привязывает к email + опционально candidate_id, отправляет
-- ссылку <PUBLIC_BASE>/public/learning/<resource_type>/<token>.
--
-- При открытии:
--   1. Фронт спрашивает email
--   2. Бэк сверяет с intended_email (case-insensitive). Не совпало → 403.
--   3. Создаём test_attempt с public_token_id, привязываем opened_at.
--   4. Юзер проходит, отправляет — used_at заполняется.
--
-- max_attempts > 1 разрешает несколько прохождений по одной ссылке (например,
-- для tutorial-теста). По умолчанию 1.
CREATE TABLE public_tokens (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    -- token: длинная случайная строка; ходит в URL. Делаем поле UNIQUE,
    -- чтобы повторно открыть ссылку и проверить, что она ещё действует.
    token           TEXT NOT NULL UNIQUE,
    resource_type   TEXT NOT NULL CHECK (resource_type IN ('test','course')),
    resource_id     UUID NOT NULL,
    intended_email  TEXT NOT NULL,
    -- candidate_id: если HR создавал ссылку из карточки candidate'а —
    -- сохраняем для join'а результатов с candidates-сервисом.
    candidate_id    BIGINT NULL,
    max_attempts    INT NOT NULL DEFAULT 1 CHECK (max_attempts > 0),
    used_attempts   INT NOT NULL DEFAULT 0,
    expires_at      TIMESTAMPTZ NULL,
    -- opened_at: первый раз когда токен был «активирован» (открыт +
    -- прошёл email-проверку). Для аудита.
    opened_at       TIMESTAMPTZ NULL,
    -- used_at: первый submitted attempt. После этого, если max_attempts=1,
    -- ссылка не открывается заново.
    used_at         TIMESTAMPTZ NULL,
    -- revoked_at: HR может вручную отозвать ссылку до использования.
    revoked_at      TIMESTAMPTZ NULL,
    created_by      UUID NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_public_tokens_resource  ON public_tokens(resource_type, resource_id);
CREATE INDEX idx_public_tokens_candidate ON public_tokens(candidate_id)
    WHERE candidate_id IS NOT NULL;
CREATE INDEX idx_public_tokens_email     ON public_tokens(LOWER(intended_email));

COMMIT;
