-- 150_objective_mapping_backfill.sql
-- Issue #307 OBJ-2 (P0 objective goldmine) — massive fuzzy backfill of
-- objective_complex_mapping so Site Finder velocity/layout analytics covers a
-- much larger share of Objective prices.
--
-- CONTEXT
--   objective_complex_mapping связывает Objective-ЖК (objective_complex_name)
--   c domrf_kn_objects.obj_id. До этой миграции — 148 строк (все is_reviewed=true,
--   objective_group='Екатеринбург'). objective_lots содержит 419 distinct
--   project_name → 271 ещё не сматчены.
--
--   mv_layout_velocity JOIN-ит objective_corpus_room_month.project_name =
--   objective_complex_mapping.objective_complex_name WHERE objective_group='Екатеринбург'.
--   То есть новые mapping-строки с group='Екатеринбург' автоматически расширяют MV.
--
-- ПОДХОД (калибровка проведена на prod read-only 2026-06-13, latest snapshot 2026-06-10):
--   Сырой trigram similarity по comm_name занижается boilerplate-префиксами
--   ('Жилой комплекс "..."', 'ЖК', 'Квартал', 'Дом', кавычки, '… N этап'),
--   из-за чего идентичные core-имена дают similarity 0.40-0.44 и теряются.
--   Поэтому нормализуем ОБА имени (strip boilerplate + кавычки + stage-суффиксы)
--   ДО similarity. Strip-токены матчатся по word-boundary (\m…\M), чтобы не
--   резать середину слова ('Домино'→'ино', 'Доминанта', 'Кварталы…').
--
--   Developer-строки шумные (Latin vs Cyrillic: 'FORTIS Development' vs
--   'Фортис Девелопмент'; юр.префиксы 'СЗ/ГК/Группа/ООО'), поэтому dev
--   нормализуем (strip юр.префиксов) и считаем dsim = trigram ИЛИ
--   substring-containment.
--
-- ПРАВИЛО ПРИЁМКИ — PRECISION OVER RECALL (P0: ложные mapping напрямую портят
--   velocity/prices, поэтому берём ТОЛЬКО dev-corroborated матчи):
--   accept = (nsim >= 0.92 AND dsim >= 0.5)         (near-exact name + dev agreement)
--         OR (nsim >= 0.62 AND dsim >= 0.5)         (good name + dev agreement)
--   == эффективно (nsim >= 0.62 AND dsim >= 0.5): КАЖДЫЙ авто-матч обязан иметь
--   согласие застройщика. Standalone nsim>=0.92 БЕЗ dev убран намеренно —
--   он ловил generic-имена с РАЗНЫМ девелопером (Солнечный/Прекрасный/Стрелки/
--   Невский/Тихий центр → FP). Цена: ~28 distinctive, но dev-noisy true-матчей
--   (Основа/Тактика/Самоцветы Урала) НЕ замапятся авто — Anton добавит вручную.
--   Низкоконфидентный хвост (nsim<0.62) тоже НЕ вставляется.
--
-- COLLISION HANDLING
--   1. Лучший domrf-obj на каждый objective-проект: DISTINCT ON (project_name)
--      по similarity DESC.
--   2. Если два objective-проекта целятся в один obj_id, оставляем лучший
--      (ROW_NUMBER per obj_id ORDER BY dsim DESC, nsim DESC). Пример obj 12789
--      = 'Жилой комплекс "Парковый квартал"' (dev PRINZIP): и 'Парковый квартал'
--      (PRINZIP, dsim высокий), и 'Жилой квартал Парковый' (ГК Вавилон) после
--      нормализации дают core 'парковый'. Побеждает PRINZIP по dsim; вариант
--      Вавилона имеет dsim=0.0 (другой застройщик) → отбрасывается. С новым
--      правилом dsim>=0.5 он и так не прошёл бы глобальный accept-gate.
--      (Аналогично 'Рио'/'Рио-3' Стройтэк целятся в один obj 31874.)
--   3. obj_id, уже занятые существующими 148 mapping, исключаются (NOT IN).
--
-- РЕЗУЛЬТАТ (verified prod, dry-run BEGIN…ROLLBACK 2026-06-13): ~114 новых mapping
--   (107 confidence=high + 7 confidence=mid, все dev-corroborated). Все
--   is_reviewed=FALSE — Anton ревьюит вручную; note содержит confidence-tier,
--   matched comm_name и dev. Часть имён имеет строки в objective_corpus_room_month
--   → немедленно расширяют mv_layout_velocity.
--   Честно: не все 271 unmapped замапятся — Objective шире domrf (satellite-города,
--   проекты без объявленной ПД в наш.дом.рф) + намеренно отброшены ~28 dev-noisy
--   и низкоконфидентный хвост. Остаток — для ручной разметки Anton'ом.
--
-- objective_group='Екатеринбург' для ВСЕХ кандидатов: это региональный label
--   ЕКБ-агломерации (region_cd=66), не строго город — существующие 148 включают
--   1 проект не из самого ЕКБ под тем же label. Так новые mapping тоже кормят MV.
--
-- EXPLAIN (prod): Nested Loop с similarity() Join Filter по 419×1392 парам,
--   Execution ~4 s. GIN/GIST индекс НЕ нужен и НЕ используется: предикат
--   similarity()>порог (а не оператор %) через CTE не триггерит trgm-индекс;
--   существующий idx_domrf_kn_objects_comm_name_trgm partial WHERE is_ekb=true,
--   а latest snapshot имеет is_ekb=false → бесполезен. 4 с для one-shot backfill
--   приемлемо (старый timeout миграции 116 был 89M-cost из-за CROSS JOIN LATERAL).
--
-- DEPENDENCIES: objective_lots, objective_complex_mapping, domrf_kn_objects,
--   pg_trgm (1.6, установлен), mv_layout_velocity (matview, UNIQUE pk → CONCURRENTLY).
-- DEPLOY: auto-applied by deploy.yml via _schema_migrations (ровно один раз по NN order).
-- ORDER: schema/data first → backend deploy second.
-- IDEMPOTENT: ON CONFLICT (objective_complex_name, objective_group) DO NOTHING.
--   Повторный запуск ничего не добавит (имена уже в mapping). Никакого DROP/мутаций
--   существующих 148 строк.

BEGIN;

WITH
-- 1. Latest DOM.РФ snapshot, регион 66 (Свердловская обл / ЕКБ-агломерация).
--    (obj_id, snapshot_date) UNIQUE → одна строка на obj_id; DISTINCT ON — страховка.
latest AS (
    SELECT MAX(snapshot_date) AS sd FROM domrf_kn_objects
),
domrf AS (
    SELECT DISTINCT ON (o.obj_id)
        o.obj_id,
        o.comm_name,
        o.dev_name,
        o.district_name,
        -- normalized commercial name: strip ЖК-boilerplate, quotes, stage suffixes.
        -- \m…\M = PG word boundaries → токены матчатся как ЦЕЛЫЕ слова, не подстроки
        -- (иначе over-strip: «Домино»→«ино», «Доминанта»→«инанта», «Кварталы…»→«ы…»).
        btrim(regexp_replace(
            regexp_replace(
                regexp_replace(lower(coalesce(o.comm_name, '')),
                    '\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M',
                    ' ', 'g'),
                '\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'),
            '[«»"''.,()\-]+', ' ', 'g')) AS name_norm,
        -- normalized developer: strip legal/group prefixes
        btrim(regexp_replace(
            regexp_replace(lower(coalesce(o.dev_name, '')),
                '(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)',
                ' ', 'g'),
            '[«»"''.,()\-]+', ' ', 'g')) AS dev_norm
    FROM domrf_kn_objects o
    WHERE o.snapshot_date = (SELECT sd FROM latest)
      AND o.region_cd = 66
      AND o.comm_name IS NOT NULL
    ORDER BY o.obj_id
),
-- 2. Distinct Objective projects (один ряд на project_name; dominant dev/district/city),
--    исключая уже сматченные в group='Екатеринбург'.
obj AS (
    SELECT
        l.project_name,
        mode() WITHIN GROUP (ORDER BY l.developer) AS developer,
        mode() WITHIN GROUP (ORDER BY l.district)  AS district,
        mode() WITHIN GROUP (ORDER BY l.city)      AS city,
        max(l.objective_project_id)                AS objective_project_id
    FROM objective_lots l
    WHERE l.project_name IS NOT NULL
    GROUP BY l.project_name
),
obj_unmapped AS (
    SELECT
        o.*,
        -- symmetric нормализация project_name (см. domrf.name_norm выше: \m…\M boundaries)
        btrim(regexp_replace(
            regexp_replace(
                regexp_replace(lower(coalesce(o.project_name, '')),
                    '\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M',
                    ' ', 'g'),
                '\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'),
            '[«»"''.,()\-]+', ' ', 'g')) AS name_norm,
        btrim(regexp_replace(
            regexp_replace(lower(coalesce(o.developer, '')),
                '(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)',
                ' ', 'g'),
            '[«»"''.,()\-]+', ' ', 'g')) AS dev_norm
    FROM obj o
    WHERE NOT EXISTS (
        SELECT 1 FROM objective_complex_mapping m
        WHERE m.objective_complex_name = o.project_name
          AND m.objective_group = 'Екатеринбург'
    )
),
-- 3. Best DOM.РФ candidate per objective project (нормализованный trigram).
pairs AS (
    SELECT
        u.project_name,
        u.developer        AS obj_dev,
        u.district         AS obj_district,
        u.objective_project_id,
        d.obj_id,
        d.comm_name,
        d.dev_name         AS domrf_dev,
        d.district_name    AS domrf_district,
        similarity(u.name_norm, d.name_norm) AS nsim,
        -- dev agreement: trigram OR substring containment (после нормализации)
        GREATEST(
            similarity(u.dev_norm, d.dev_norm),
            (char_length(btrim(u.dev_norm)) > 2
             AND char_length(btrim(d.dev_norm)) > 2
             AND (u.dev_norm ILIKE '%' || d.dev_norm || '%'
                  OR d.dev_norm ILIKE '%' || u.dev_norm || '%'))::int::double precision
        ) AS dsim
    FROM obj_unmapped u
    JOIN domrf d
      ON similarity(u.name_norm, d.name_norm) > 0.2      -- prefilter
),
best_per_proj AS (
    SELECT DISTINCT ON (project_name) *
    FROM pairs
    ORDER BY project_name, nsim DESC, dsim DESC, obj_id
),
-- 4. Accept rule + resolve obj_id collisions (keep best objective per domrf obj).
accepted AS (
    SELECT *
    FROM (
        SELECT
            b.*,
            ROW_NUMBER() OVER (
                PARTITION BY b.obj_id
                ORDER BY b.dsim DESC, b.nsim DESC, b.project_name
            ) AS rn_obj
        FROM best_per_proj b
        WHERE ((b.nsim >= 0.92 AND b.dsim >= 0.5) OR (b.nsim >= 0.62 AND b.dsim >= 0.5))
          AND b.obj_id NOT IN (
              SELECT domrf_obj_id FROM objective_complex_mapping
              WHERE domrf_obj_id IS NOT NULL
          )
    ) s
    WHERE s.rn_obj = 1
)
INSERT INTO objective_complex_mapping
    (objective_complex_name, objective_project_id, objective_group,
     domrf_obj_id, is_reviewed, match_method, match_score, note)
SELECT
    a.project_name,
    a.objective_project_id,
    'Екатеринбург',
    a.obj_id,
    FALSE,                       -- ВСЕ авто-матчи на ручной review (Anton)
    'auto_fuzzy_v2',
    round(a.nsim::numeric, 3),
    'OBJ-2 #307 auto_fuzzy_v2 2026-06-13 (norm-trgm). '
        || CASE
               WHEN a.nsim >= 0.92 THEN 'confidence=high (near-exact name + dev)'
               ELSE 'confidence=mid (good name + dev)'
           END
        || ' | nsim=' || round(a.nsim::numeric, 3)
        || ' dsim=' || round(a.dsim::numeric, 3)
        || ' | domrf="' || coalesce(a.comm_name, '') || '"'
        || ' dev_obj="' || coalesce(a.obj_dev, '') || '"'
        || ' dev_domrf="' || coalesce(a.domrf_dev, '') || '"'
FROM accepted a
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING;

COMMIT;

-- REFRESH вне транзакции: CONCURRENTLY требует non-tx и UNIQUE index
-- (mv_layout_velocity_pk на (obj_id, room_bucket) существует). Не блокирует читателей.
-- Initial MV уже populated (~554 строк) — CONCURRENTLY безопасен.
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity;
