"""Унифицированный источник «наших проектов» для §25.3 каннибализации (#1169 PR1).

`get_own_portfolio(db) -> list[OwnProject]` сводит ДВА источника «нашего портфеля» в
общий нормализованный shape `OwnProject`, чтобы движок пересечения §25.3 (PR2) мог
сравнивать кандидата с нашими проектами по 4 осям: аудитория/класс, тайминг (месяц
выхода), цена (₽/м²), квартирография (unit_mix).

Два источника (origins):
  1. «Текущие» (source='current') ← domrf_kn_objects, отфильтрованные по
     settings.own_developer_ids — целочисленные DOM.РФ developer-id «наших»
     застройщиков. КАВЕАТ ФОРМАТА: domrf_kn_objects.dev_id — composite TEXT
     '<numericDevId>_<suffix>' (напр. '12345_0'), а конфиг хранит ИНТЫ. Поэтому
     матчим по ЧИСЛОВОМУ ПРЕФИКСУ dev_id (split_part(dev_id,'_',1)::int = ANY(:ids)).
     Пустой own_developer_ids → НЕ запрашиваем БД, возвращаем [] (graceful, без ошибки).
  2. «Будущие/планируемые» (source='future') ← все строки own_planned_project
     (manual-entry приватного пайплайна, миграция 148).

Оба нормализуются в OwnProject. Дисциплина None-not-0: отсутствующее поле → None
(а НЕ 0/«» — «нет данных» ≠ «ноль», чтобы PR2 деградировал честно). class-вокабуляр
и district-нейминг зеркалят forecasting SegmentSpec (sales_series.py) — OwnProject
напрямую сопоставим с кандидатом-сегментом в PR2.

Graceful degradation (дух market_metrics / sales_series):
  • own_developer_ids пусто И own_planned_project пуст → get_own_portfolio вернёт [].
  • own_developer_ids пусто, но manual-строки есть → только future-строки.
  • сбой одного из запросов → этот источник даёт [], второй считается (НЕ crash).
PR2-движок при пустом портфеле честно отдаёт прокси/None вместо фабрикации сигнала.

psycopg v3 / SQLAlchemy text: bind-параметры ВСЕГДА через CAST(:x AS type) — никогда
:x::type. Чистого SQL-каста (split_part(...)::int) избегаем в bind-контексте: пишем
CAST(... AS integer).
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import date
from typing import Any, Literal

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.core.config import settings

logger = logging.getLogger(__name__)

OwnProjectSource = Literal["current", "future"]


@dataclass(frozen=True)
class OwnProject:
    """Нормализованный «наш проект» — общий shape для §25.3 (current + future).

    Дисциплина None-not-0: любое неизвестное поле = None (НЕ 0/«»). Цена — вилка
    min/max ₽/м² (любая граница может быть None). unit_mix — доли квартирографии
    {"studio":0.3,...} или None. release_month — ПЕРВОЕ число месяца (тайминг §25.3).
    lon/lat — центроид (оба None, если координат нет). obj_class — «человеческий»
    класс (зеркало forecasting SegmentSpec; матчинг в PR2 регистронезависимый).
    """

    name: str
    source: OwnProjectSource
    obj_class: str | None
    release_month: date | None
    price_min_per_m2: float | None
    price_max_per_m2: float | None
    unit_mix: dict[str, float] | None
    district: str | None
    lon: float | None
    lat: float | None

    def as_dict(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "source": self.source,
            "obj_class": self.obj_class,
            "release_month": self.release_month.isoformat() if self.release_month else None,
            "price_min_per_m2": self.price_min_per_m2,
            "price_max_per_m2": self.price_max_per_m2,
            "unit_mix": dict(self.unit_mix) if self.unit_mix is not None else None,
            "district": self.district,
            "lon": self.lon,
            "lat": self.lat,
        }


# ── SQL ────────────────────────────────────────────────────────────────────────

# «Текущие» проекты: последний snapshot domrf_kn_objects «наших» застройщиков.
# Матчинг по числовому префиксу composite dev_id (см. module docstring). Берём
# только последний snapshot на dev_id, чтобы не дублировать объект по истории.
# ready_dt → release_month (нормализуем к 1-му числу). Цена ₽/м² — вилка
# price_per_m2_min/max (22e-колонки; могут быть NULL → None). district_name (56/57).
_CURRENT_SQL = text(
    """
    WITH ours AS (
        SELECT o.*
          FROM domrf_kn_objects o
         WHERE o.dev_id IS NOT NULL
           AND o.dev_id ~ '^[0-9]+(_|$)'
           AND CAST(split_part(o.dev_id, '_', 1) AS integer) = ANY(CAST(:ids AS integer[]))
    ),
    latest AS (
        SELECT obj_id, MAX(snapshot_date) AS snap
          FROM ours
         GROUP BY obj_id
    )
    SELECT o.obj_id,
           o.comm_name,
           o.obj_class,
           o.district_name,
           o.ready_dt,
           o.price_per_m2_min,
           o.price_per_m2_max,
           o.latitude,
           o.longitude
      FROM ours o
      JOIN latest l ON l.obj_id = o.obj_id AND l.snap = o.snapshot_date
     ORDER BY o.ready_dt DESC NULLS LAST, o.obj_id
    """
)

# «Будущие/планируемые»: все строки own_planned_project (миграция 148). unit_mix —
# jsonb (psycopg3 → dict). planned_release_month уже нормализован к 1-му числу.
_FUTURE_SQL = text(
    """
    SELECT id,
           name,
           obj_class,
           district,
           planned_release_month,
           price_min_per_m2,
           price_max_per_m2,
           unit_mix,
           lon,
           lat
      FROM own_planned_project
     ORDER BY planned_release_month DESC NULLS LAST, id
    """
)


def _f(value: Any) -> float | None:
    """NUMERIC/Decimal/float → float | None (None-not-0: NULL остаётся None)."""
    if value is None:
        return None
    return float(value)


def _month_start(value: date | None) -> date | None:
    """Нормализовать дату к ПЕРВОМУ числу месяца. None → None (None-not-0)."""
    if value is None:
        return None
    return value.replace(day=1)


def _query_current(db: Session) -> list[OwnProject]:
    """«Текущие» проекты из domrf по settings.own_developer_ids. Graceful → [].

    Пустой own_developer_ids → СРАЗУ [] (не идём в БД — нечего матчить, это штатная
    деградация, а не ошибка). Сбой запроса → [] (второй источник не должен страдать).
    """
    ids = list(settings.own_developer_ids)
    if not ids:
        logger.info(
            "own_portfolio: own_developer_ids пуст → current-проектов нет "
            "(graceful; §25.3 деградирует на future/None)"
        )
        return []
    try:
        # SAVEPOINT — db shared с caller (get_own_portfolio в цепочке §25.3 / analyze).
        # Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
        # begin_nested откатывает только эту savepoint, оставляя db usable для
        # _query_future (следующий вызов на этой же сессии в get_own_portfolio).
        with db.begin_nested():
            rows = db.execute(_CURRENT_SQL, {"ids": ids}).mappings().all()
    except Exception:
        logger.exception("own_portfolio: current (domrf) query failed → []")
        return []
    out: list[OwnProject] = []
    for r in rows:
        out.append(
            OwnProject(
                name=r["comm_name"] or f"obj {r['obj_id']}",
                source="current",
                obj_class=r["obj_class"],
                release_month=_month_start(r["ready_dt"]),
                price_min_per_m2=_f(r["price_per_m2_min"]),
                price_max_per_m2=_f(r["price_per_m2_max"]),
                # domrf-объект не несёт долей квартирографии в этом запросе → None
                # (PR2 может добрать из domrf_kn_flats отдельно; здесь не фабрикуем).
                unit_mix=None,
                district=r["district_name"],
                lon=_f(r["longitude"]),
                lat=_f(r["latitude"]),
            )
        )
    return out


def _query_future(db: Session) -> list[OwnProject]:
    """«Будущие/планируемые» проекты из own_planned_project. Graceful → [].

    Сбой/отсутствие таблицы (cold-start до миграции 148) → [] (НЕ crash).
    """
    try:
        # SAVEPOINT — db shared с caller (см. _query_current выше). begin_nested
        # откатывает только эту savepoint (НЕ голый db.rollback(), см. velocity.py:170),
        # оставляя db usable для остальных потребителей той же сессии (§25.3 / analyze).
        with db.begin_nested():
            rows = db.execute(_FUTURE_SQL).mappings().all()
    except Exception:
        logger.exception("own_portfolio: future (own_planned_project) query failed → []")
        return []
    out: list[OwnProject] = []
    for r in rows:
        unit_mix = r["unit_mix"]
        out.append(
            OwnProject(
                name=r["name"],
                source="future",
                obj_class=r["obj_class"],
                release_month=_month_start(r["planned_release_month"]),
                price_min_per_m2=_f(r["price_min_per_m2"]),
                price_max_per_m2=_f(r["price_max_per_m2"]),
                unit_mix=dict(unit_mix) if isinstance(unit_mix, dict) else None,
                district=r["district"],
                lon=_f(r["lon"]),
                lat=_f(r["lat"]),
            )
        )
    return out


def get_own_portfolio(db: Session) -> list[OwnProject]:
    """Собрать унифицированный «наш портфель» (current + future) для §25.3 (PR2).

    Объединяет два источника, нормализованных в OwnProject:
      • current ← domrf_kn_objects по settings.own_developer_ids (пусто → [], graceful);
      • future  ← все строки own_planned_project (manual-entry).

    Каждый источник в собственном try/except (см. _query_current/_query_future): сбой
    одного НЕ валит второй. Дисциплина None-not-0 соблюдается в маппинге. Возвращается
    ВСЕГДА (никогда не crash); пустой список — штатная деградация, когда own_developer_ids
    пуст И manual-строк нет. PR2-движок при пустом портфеле честно отдаёт прокси/None.

    Args:
        db: SQLAlchemy sync Session.

    Returns:
        list[OwnProject] — объединённый портфель (current впереди future). Может быть [].
    """
    current = _query_current(db)
    future = _query_future(db)
    portfolio = current + future
    logger.info(
        "own_portfolio: собрано %d проектов (current=%d future=%d, own_developer_ids=%d)",
        len(portfolio),
        len(current),
        len(future),
        len(settings.own_developer_ids),
    )
    return portfolio
