-- Подключение расширения pgcrypto для генерации UUID.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS binding;
CREATE SCHEMA IF NOT EXISTS oauth;

CREATE TABLE IF NOT EXISTS binding.portals (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    -- member_id - уникальный идентификатор портала из Битрикса.
    member_id text NOT NULL UNIQUE,
    -- domain - домен портала, например: example.bitrix24.ru.
    domain text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS binding.tokens (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
    bitrix_user_id bigint NOT NULL,
    -- Хеш токена, генерируется сервером.
    token_hash bytea NOT NULL UNIQUE,
    expires_at timestamptz NOT NULL,
    consumed_at timestamptz,
    revoked_at timestamptz,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS binding_tokens_owner_idx
    ON binding.tokens (portal_id, bitrix_user_id, created_at DESC);

CREATE TABLE IF NOT EXISTS binding.user_bindings (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
    bitrix_user_id bigint NOT NULL,
    telegram_user_id bigint NOT NULL,
    telegram_chat_id bigint NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now(),
    UNIQUE (portal_id, bitrix_user_id),
    UNIQUE (portal_id, telegram_user_id)
);

CREATE TABLE IF NOT EXISTS oauth.user_credentials (
    portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
    bitrix_user_id bigint NOT NULL,
    access_token bytea NOT NULL,
    refresh_token bytea NOT NULL,
    expires_at timestamptz NOT NULL,
    -- Двойной механизм защиты от гонок данных при обновлении токенов.
    version bigint NOT NULL DEFAULT 1,
    refresh_locked_until timestamptz,
    updated_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (portal_id, bitrix_user_id)
);

CREATE OR REPLACE FUNCTION binding.issue_v1(
    p_member_id text,
    p_domain text,
    p_bitrix_user_id bigint,
    p_token_hash bytea,
    p_token_expires_at timestamptz,
    p_access_token bytea,
    p_refresh_token bytea,
    p_oauth_expires_at timestamptz
)
RETURNS TABLE(token_id uuid, expires_at timestamptz)
LANGUAGE plpgsql
-- Задаем SECURITY DEFINER, чтобы функция выполнялась с правами владельца
-- схемы binding.
SECURITY DEFINER
-- Устанавливаем search_path в pg_catalog, чтобы нельзя было подменить
-- функции в схеме binding или oauth.
SET search_path = pg_catalog
AS $function$
#variable_conflict error
DECLARE
    v_portal_id bigint;
    v_token_id uuid;
BEGIN
    -- Добавляем данные о портале, если его ещё нет,
    -- или обновляем домен, если портал уже существует.
    INSERT INTO binding.portals(member_id, domain)
    VALUES (p_member_id, lower(p_domain))
    ON CONFLICT (member_id) DO UPDATE
        SET domain = EXCLUDED.domain,
            updated_at = now()
    RETURNING id INTO v_portal_id;

    -- Сохраняем OAuth-данные пользователя, если их ещё нет,
    -- или обновляем их, если они уже существуют.
    INSERT INTO oauth.user_credentials(
        portal_id,
        bitrix_user_id,
        access_token,
        refresh_token,
        expires_at
    )
    VALUES (
        v_portal_id,
        p_bitrix_user_id,
        p_access_token,
        p_refresh_token,
        p_oauth_expires_at
    )
    ON CONFLICT (portal_id, bitrix_user_id) DO UPDATE
        SET access_token = EXCLUDED.access_token,
            refresh_token = EXCLUDED.refresh_token,
            expires_at = EXCLUDED.expires_at,
            version = oauth.user_credentials.version + 1,
            refresh_locked_until = NULL,
            updated_at = now();

    -- Отзываем все предыдущие неиспользованные токены пользователя.
    UPDATE binding.tokens
    SET revoked_at = now()
    WHERE portal_id = v_portal_id
      AND bitrix_user_id = p_bitrix_user_id
      AND consumed_at IS NULL
      AND revoked_at IS NULL;

    -- Выпускаем новый токен привязки.
    INSERT INTO binding.tokens(
        portal_id,
        bitrix_user_id,
        token_hash,
        expires_at
    )
    VALUES (
        v_portal_id,
        p_bitrix_user_id,
        p_token_hash,
        p_token_expires_at
    )
    RETURNING id INTO v_token_id;

    -- Возвращаем идентификатор токена и срок его действия.
    RETURN QUERY SELECT v_token_id, p_token_expires_at;
END
$function$;

COMMENT ON FUNCTION binding.issue_v1(
    text,
    text,
    bigint,
    bytea,
    timestamptz,
    bytea,
    bytea,
    timestamptz
)
IS $doc$
Сохраняет OAuth-данные пользователя и выпускает токен привязки.

Гарантии:
- предыдущие неиспользованные токены отзываются;
- при обновлении OAuth-данных увеличивается их версия.

Возвращает:
- token_id - идентификатор токена;
- expires_at - срок действия токена.
$doc$;

CREATE OR REPLACE FUNCTION binding.consume_v1(
    p_token_hash bytea,
    p_telegram_user_id bigint,
    p_telegram_chat_id bigint
)
RETURNS TABLE(
    member_id text,
    domain text,
    bitrix_user_id bigint,
    telegram_user_id bigint
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
#variable_conflict error
DECLARE
    v_portal_id bigint;
    v_bitrix_user_id bigint;
BEGIN
    -- UPDATE не позволит двум запросам погасить один токен.
    UPDATE binding.tokens AS token
    SET consumed_at = now()
    WHERE token.token_hash = p_token_hash
      AND token.consumed_at IS NULL
      AND token.revoked_at IS NULL
      AND token.expires_at > now()
    RETURNING token.portal_id, token.bitrix_user_id
        INTO v_portal_id, v_bitrix_user_id;

    -- Если UPDATE не вернул ни одной строки, значит токен недействителен.
    IF NOT FOUND THEN
        RETURN;
    END IF;

    -- Удаляем все привязки к Telegram для данного портала и пользователя Битрикса,
    -- кроме той, которая соответствует текущему пользователю Битрикса.
    DELETE FROM binding.user_bindings AS user_binding
    WHERE user_binding.portal_id = v_portal_id
      AND user_binding.telegram_user_id = p_telegram_user_id
      AND user_binding.bitrix_user_id <> v_bitrix_user_id;

    -- Добавляем или обновляем привязку к Telegram для данного портала
    -- и пользователя Битрикса.
    INSERT INTO binding.user_bindings(
        portal_id,
        bitrix_user_id,
        telegram_user_id,
        telegram_chat_id
    )
    VALUES (
        v_portal_id,
        v_bitrix_user_id,
        p_telegram_user_id,
        p_telegram_chat_id
    )
    ON CONFLICT ON CONSTRAINT user_bindings_pkey DO UPDATE
        SET telegram_user_id = EXCLUDED.telegram_user_id,
            telegram_chat_id = EXCLUDED.telegram_chat_id,
            updated_at = now();

    RETURN QUERY
    SELECT
        portal.member_id,
        portal.domain,
        v_bitrix_user_id,
        p_telegram_user_id
    FROM binding.portals AS portal
    WHERE portal.id = v_portal_id;
END
$function$;

COMMENT ON FUNCTION binding.consume_v1(
    bytea,
    bigint,
    bigint
)
IS $doc$
Погашает токен привязки и сохраняет привязку к Telegram.

Гарантии:
- токен погашается только один раз;
- если токен недействителен, функция возвращает пустой результат;
- если токен действителен, функция возвращает данные портала и пользователя
Битрикса, а также сохраняет привязку к Telegram;
- если пользователь Битрикса уже был привязан к другому пользователю Telegram,
старая привязка удаляется.

Возвращает:
- member_id - идентификатор портала;
- domain - домен портала;
- bitrix_user_id - идентификатор пользователя Битрикса;
- telegram_user_id - идентификатор пользователя Telegram.
$doc$;

CREATE OR REPLACE FUNCTION binding.find_by_telegram_v1(
    p_telegram_user_id bigint,
    p_member_id text DEFAULT NULL
)
RETURNS TABLE(
    member_id text,
    domain text,
    bitrix_user_id bigint,
    telegram_user_id bigint
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
    SELECT
        portal.member_id,
        portal.domain,
        user_binding.bitrix_user_id,
        user_binding.telegram_user_id
    FROM binding.user_bindings AS user_binding
    JOIN binding.portals AS portal ON portal.id = user_binding.portal_id
    WHERE user_binding.telegram_user_id = p_telegram_user_id
      AND (p_member_id IS NULL OR portal.member_id = p_member_id)
    ORDER BY user_binding.updated_at DESC
    LIMIT 1
$function$;

COMMENT ON FUNCTION binding.find_by_telegram_v1(
    bigint,
    text
)
IS $doc$
Находит привязку к Telegram по идентификатору пользователя Telegram.

Возвращает:
- member_id - идентификатор портала;
- domain - домен портала;
- bitrix_user_id - идентификатор пользователя Битрикса;
- telegram_user_id - идентификатор пользователя Telegram.
Если p_member_id не NULL, то поиск ограничивается указанным порталом.
$doc$;

CREATE OR REPLACE FUNCTION oauth.get_credentials_v1(
    p_member_id text,
    p_bitrix_user_id bigint
)
RETURNS TABLE(
    member_id text,
    domain text,
    bitrix_user_id bigint,
    access_token bytea,
    refresh_token bytea,
    expires_at timestamptz,
    version bigint
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
    SELECT
        portal.member_id,
        portal.domain,
        credentials.bitrix_user_id,
        credentials.access_token,
        credentials.refresh_token,
        credentials.expires_at,
        credentials.version
    FROM oauth.user_credentials AS credentials
    JOIN binding.portals AS portal ON portal.id = credentials.portal_id
    WHERE portal.member_id = p_member_id
      AND credentials.bitrix_user_id = p_bitrix_user_id
$function$;

COMMENT ON FUNCTION oauth.get_credentials_v1(
    text,
    bigint
)
IS $doc$
Находит OAuth-данные пользователя по идентификатору портала и идентификатору
пользователя Битрикса.

Возвращает:
- member_id - идентификатор портала;
- domain - домен портала;
- bitrix_user_id - идентификатор пользователя Битрикса;
- access_token - токен доступа;
- refresh_token - токен обновления;
- expires_at - срок действия токена доступа;
- version - версия данных.
$doc$;

CREATE OR REPLACE FUNCTION oauth.claim_refresh_v1(
    p_member_id text,
    p_bitrix_user_id bigint,
    p_version bigint
)
RETURNS boolean
LANGUAGE sql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
    -- Создаем временную таблицу claimed,
    -- которая будет содержать результат обновления (CTE).
    WITH claimed AS (
        UPDATE oauth.user_credentials AS credentials
        SET refresh_locked_until = now() + interval '30 seconds'
        FROM binding.portals AS portal
        WHERE credentials.portal_id = portal.id
          AND portal.member_id = p_member_id
          AND credentials.bitrix_user_id = p_bitrix_user_id
          AND credentials.version = p_version
          AND (
              credentials.refresh_locked_until IS NULL
              OR credentials.refresh_locked_until < now()
          )
        RETURNING 1
    )
    SELECT EXISTS(SELECT 1 FROM claimed)
$function$;

COMMENT ON FUNCTION oauth.claim_refresh_v1(
    text,
    bigint,
    bigint
)
IS $doc$
Пытается захватить токен обновления для пользователя.

Возвращает:
- true, если захват успешен;
- false, если захват не удался.
$doc$;

CREATE OR REPLACE FUNCTION oauth.finish_refresh_v1(
    p_member_id text,
    p_bitrix_user_id bigint,
    p_version bigint,
    p_access_token bytea,
    p_refresh_token bytea,
    p_expires_at timestamptz
)
RETURNS boolean
LANGUAGE sql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
    WITH updated AS (
        UPDATE oauth.user_credentials AS credentials
        SET access_token = p_access_token,
            refresh_token = p_refresh_token,
            expires_at = p_expires_at,
            version = credentials.version + 1,
            refresh_locked_until = NULL,
            updated_at = now()
        FROM binding.portals AS portal
        WHERE credentials.portal_id = portal.id
          AND portal.member_id = p_member_id
          AND credentials.bitrix_user_id = p_bitrix_user_id
          AND credentials.version = p_version
        RETURNING 1
    )
    SELECT EXISTS(SELECT 1 FROM updated)
$function$;

COMMENT ON FUNCTION oauth.finish_refresh_v1(
    text,
    bigint,
    bigint,
    bytea,
    bytea,
    timestamptz
) IS $doc$
Завершает процесс обновления токена для пользователя.

Возвращает:
- true, если обновление успешно завершено;
- false, если обновление не удалось.
$doc$;

CREATE OR REPLACE FUNCTION oauth.release_refresh_v1(
    p_member_id text,
    p_bitrix_user_id bigint,
    p_version bigint
)
RETURNS void
LANGUAGE sql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
    UPDATE oauth.user_credentials AS credentials
    SET refresh_locked_until = NULL
    FROM binding.portals AS portal
    WHERE credentials.portal_id = portal.id
      AND portal.member_id = p_member_id
      AND credentials.bitrix_user_id = p_bitrix_user_id
      AND credentials.version = p_version
$function$;

COMMENT ON FUNCTION oauth.release_refresh_v1(
    text,
    bigint,
    bigint
) IS $doc$
Освобождает токен обновления для пользователя.

Возвращает:
- void.
$doc$;

-- Отзываем все права у PUBLIC.
REVOKE ALL ON ALL TABLES IN SCHEMA binding, oauth FROM PUBLIC;
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA binding, oauth FROM PUBLIC;

-- Даем права на использование схемы и выполнение функций ролям site_role
-- и bot_role.
GRANT USAGE ON SCHEMA binding TO site_role, bot_role;
GRANT USAGE ON SCHEMA oauth TO bot_role;

-- Даем права на выполнение функций ролям site_role и bot_role.
GRANT EXECUTE ON FUNCTION binding.issue_v1(
    text, text, bigint, bytea, timestamptz, bytea, bytea, timestamptz
) TO site_role;
GRANT EXECUTE ON FUNCTION binding.consume_v1(bytea, bigint, bigint) TO bot_role;
GRANT EXECUTE ON FUNCTION binding.find_by_telegram_v1(bigint, text) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.get_credentials_v1(text, bigint) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.claim_refresh_v1(text, bigint, bigint) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.finish_refresh_v1(
    text, bigint, bigint, bytea, bytea, timestamptz
) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.release_refresh_v1(text, bigint, bigint)
    TO bot_role;
