-- 133_listings_uq_source_source_id.sql
-- Дедуп дублей listings по (source, source_id) + UNIQUE-констрейнт против повторов.
--
-- WHY (root cause — #1773):
--   Один и тот же оффер задваивается в listings: cian 330047129 лежит в ДВУХ
--   строках — одна с house_id_fk (привязана к дому), другая с house_id_fk=NULL.
--   Корень — историческая смена формулы dedup_hash (#753): раньше hash включал
--   цену, поэтому каждое изменение цены порождало новую строку с другим
--   dedup_hash. Текущий UPSERT идёт ON CONFLICT (dedup_hash), а старые «ghost»-
--   строки несут price-inclusive hash, который новым апсертом уже не матчится →
--   живут вечно как дубль того же (source, source_id). 113_deactivate_ghost_
--   duplicate_listings.sql лишь ГАСИЛ (is_active=false) лишние строки, но
--   физически их не удалял и не запрещал появление новых → дубли остаются и
--   рвут same-building anchor / раздувают инвентарь.
--
-- WHAT:
--   1) Внутри каждой группы (source, source_id) с >1 строк выбираем «лучшую»
--      строку-keeper и схлопываем группу на неё:
--        keeper = приоритет (house_id_fk IS NOT NULL) DESC,
--                 затем is_active DESC, затем last_seen_at DESC, затем id DESC.
--      То есть оставляем строку, уже привязанную к дому и самую свежую.
--   2) Переносим на keeper только self-ref listings.merged_into (нет UNIQUE с
--      listing_id → без коллизий). Дочерние ДАННЫЕ loser'ов (offer_price_history,
--      listings_snapshots, listing_sources, external_valuations) НЕ переносим:
--      их UNIQUE/PK включают listing_id, а несколько loser'ов одной группы несут
--      одинаковый change_time/snapshot_date → перенос на keeper в одном UPDATE
--      коллидирует МЕЖДУ loser'ами (прод-факт: offer_price_history_listing_change_uq).
--      Это дубли одного оффера — keeper хранит свою полную историю; loser-дубли
--      избыточны.
--   3) Удаляем loser-строки. FK ON DELETE CASCADE подчищает ВСЕХ их детей.
--   4) Вешаем UNIQUE-индекс на (source, source_id) WHERE source_id IS NOT NULL —
--      частичный, т.к. источники без source_id (yandex/url-only) дают source_id
--      NULL, а NULL в Postgres-UNIQUE считаются различными → их не трогаем.
--
-- IDEMPOTENCY / SAFETY (важно для прода):
--   - Весь файл в одной транзакции BEGIN/COMMIT — атомарно.
--   - keeper и все ЕГО дети сохраняются полностью. Удаляются только дубли-loser
--     и их дети (CASCADE) — это история одного и того же оффера, у keeper своя.
--   - Повторный прогон: после первого раза дублей нет → дедуп-шаги no-op;
--     индекс создаётся через CREATE UNIQUE INDEX IF NOT EXISTS → no-op.
--   - source_id IS NULL строки полностью игнорируются (частичный индекс).
--
-- RELATION к app/services/scrapers/base.py save_listings:
--   save_listings апсертит ON CONFLICT (dedup_hash). Текущая формула dedup_hash =
--   sha256(source || '|' || source_id) детерминирована из (source, source_id),
--   поэтому одинаковый (source, source_id) всегда даёт ОДИН dedup_hash →
--   ON CONFLICT (dedup_hash) и так не вставит второй раз. Новый UNIQUE
--   (source, source_id) согласован с этим (страхует только от
--   историко-формульных дублей), НЕ конфликтует с апсертом и НЕ требует менять
--   save_listings на ON CONFLICT (source, source_id). source_id=NULL не попадает
--   под частичный индекс → апсерты yandex/url-only не ломаются.
--
-- DEPENDENCIES:
--   - listings (id, source, source_id, house_id_fk, is_active, last_seen_at,
--     merged_into) -- 002_core_tables.sql + 011_listings_alter.sql + 028
--   - offer_price_history.listing_id -> listings(id) ON DELETE CASCADE -- 023
--   - listings_snapshots PK (listing_id, snapshot_date) -- 016
--   - listing_sources UNIQUE (ext_source, ext_id) -- 028
--   - external_valuations partial UNIQUE (source, house_id, listing_id) -- 044
--
-- Deploy order: after 131_fix_diff_percent_overflow.sql (latest revision).

BEGIN;

DO $$
DECLARE
    v_groups          bigint;
    v_losers          bigint;
    v_deleted         bigint;
    v_remaining_dupes bigint;
BEGIN
    -- ---------------------------------------------------------------------
    -- 0. Материализуем keeper-per-group и список loser-строк во временную
    --    таблицу. keeper выбираем оконной функцией с детерминированным
    --    ORDER BY: привязка к дому > активность > свежесть > id.
    -- ---------------------------------------------------------------------
    CREATE TEMP TABLE _ld_map ON COMMIT DROP AS
    WITH ranked AS (
        SELECT
            id,
            source,
            source_id,
            first_value(id) OVER (
                PARTITION BY source, source_id
                ORDER BY
                    (house_id_fk IS NOT NULL) DESC,
                    is_active DESC,
                    last_seen_at DESC NULLS LAST,
                    id DESC
            ) AS keeper_id
        FROM listings
        WHERE source_id IS NOT NULL
          AND (source, source_id) IN (
              SELECT source, source_id
              FROM listings
              WHERE source_id IS NOT NULL
              GROUP BY source, source_id
              HAVING count(*) > 1
          )
    )
    SELECT id AS loser_id, keeper_id
    FROM ranked
    WHERE id <> keeper_id;

    SELECT count(*)                       INTO v_losers FROM _ld_map;
    SELECT count(DISTINCT keeper_id)      INTO v_groups FROM _ld_map;

    IF v_losers = 0 THEN
        RAISE NOTICE 'migration 133: no duplicate (source, source_id) groups — dedup no-op';
    ELSE
        -- -----------------------------------------------------------------
        -- 1. self-ref merged_into: loser → keeper. Это единственный безопасный
        --    перенос — у merged_into нет UNIQUE-ключа с listing_id, коллизий нет.
        --
        --    Дочерние ДАННЫЕ loser'ов (offer_price_history, listings_snapshots,
        --    listing_sources, external_valuations) НЕ переносим: все их UNIQUE/PK
        --    включают listing_id, и НЕСКОЛЬКО loser'ов одной группы могут нести
        --    одинаковый change_time/snapshot_date/(source,house_id) → перенос на
        --    keeper в одном UPDATE даёт коллизию МЕЖДУ САМИМИ loser'ами (а не только
        --    с keeper'ом), которую NOT EXISTS против keeper'а не ловит (прод-факт:
        --    offer_price_history_listing_change_uq). Это дубли ОДНОГО оффера —
        --    keeper хранит свою полную историю, история loser-дублей избыточна.
        --    Все FK имеют ON DELETE CASCADE (confdeltype='c') → дочерние loser-строки
        --    удаляются автоматически при DELETE loser на шаге 2. Без коллизий.
        -- -----------------------------------------------------------------
        UPDATE listings l
        SET merged_into = m.keeper_id
        FROM _ld_map m
        WHERE l.merged_into = m.loser_id;

        -- -----------------------------------------------------------------
        -- 2. Удаляем loser-строки. FK ON DELETE CASCADE подчищает всех их детей
        --    (offer_price_history / listings_snapshots / listing_sources /
        --    external_valuations) — у keeper своя полная история.
        -- -----------------------------------------------------------------
        DELETE FROM listings l
        USING _ld_map m
        WHERE l.id = m.loser_id;

        GET DIAGNOSTICS v_deleted = ROW_COUNT;

        RAISE NOTICE
            'migration 133: dedup done — groups=% losers=% deleted=% (kept best row per group)',
            v_groups, v_losers, v_deleted;
    END IF;

    -- Контроль: после дедупа не должно остаться групп с дублями.
    SELECT count(*) INTO v_remaining_dupes
    FROM (
        SELECT 1
        FROM listings
        WHERE source_id IS NOT NULL
        GROUP BY source, source_id
        HAVING count(*) > 1
    ) d;

    IF v_remaining_dupes > 0 THEN
        RAISE EXCEPTION
            'migration 133: still % duplicate (source, source_id) groups after dedup — aborting',
            v_remaining_dupes;
    END IF;
END $$;

-- -------------------------------------------------------------------------
-- 3. UNIQUE-индекс против будущих дублей. Частичный (source_id IS NOT NULL):
--    источники без source_id (yandex/url-only) дают NULL и не должны падать.
--    IF NOT EXISTS → идемпотентно при повторном прогоне.
-- -------------------------------------------------------------------------
CREATE UNIQUE INDEX IF NOT EXISTS listings_source_source_id_uq
    ON listings (source, source_id)
    WHERE source_id IS NOT NULL;

COMMIT;
