"""Парсер и DB-writer для планировок квартир DOM.РФ (domrf_kn_flat_plans).

Источник данных
---------------
План квартиры отображается на странице
  /сервисы/каталог-квартир/квартира/{catalog_hash}
в блоке «Планировка». URL картинки встроен в SSR-HTML страницы и недоступен
через kn-API list-endpoint (/сервисы/api/kn/object) или flat-table endpoint
(/portal-kn/api/sales/portal/table).

Audit-результат (2026-05-17, obj_id=65136)
------------------------------------------
- kn_object_place_66 payload: plan_image / planImage / planUrl — NOT FOUND.
- portal/table flat items: поля planImageUrl / layoutUrl — NOT FOUND.
- Вывод: plan image URL находится ТОЛЬКО в SSR-HTML каталога.

Структура модуля
----------------
- `extract_flat_plans(raw_payload)` — парсит plan_image_url из flat-table payload
  (заглушка — возвращает пустой список до реализации SSR-scraper или endpoint).
- `upsert_flat_plans(db, obj_id, plans, snapshot_date)` — UPSERT в
  domrf_kn_flat_plans с COALESCE-защитой скачанных файлов.
- `download_plan_image_stub()` — NotImplementedError placeholder.
  Реальная загрузка — отдельный Celery task в рамках 22d-track / #299.

Связанные файлы
---------------
  data/sql/100_22c_flat_plans.sql — DDL таблицы.
  backend/app/services/scrapers/domrf_kn.py — основной kn-scraper (НЕ трогаем
      в этом PR; wiring через отдельный PR после реализации SSR-scraper).
Issue #297 sub-task 22c.
"""

from __future__ import annotations

import io
import logging
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit

from sqlalchemy import text
from sqlalchemy.orm import Session

if TYPE_CHECKING:
    from datetime import date

    from app.services.scrapers.stealth import BrowserSession

logger = logging.getLogger(__name__)

# Базовая директория для скачанных бинарников планировок. Зеркалит конвенцию
# domrf_kn.py:PHOTOS_DIR_DEFAULT (data/raw/domrf_photos) — тот же raw-корень,
# отдельная папка. Файлы: {base}/{obj_id}/{safe_ods_id}.{ext}.
FLAT_PLANS_DIR_DEFAULT = Path("data/raw/domrf_flat_plans")

# Разрешённые расширения картинок планировок. png — дефолт (DOM.РФ отдаёт планы
# растром), svg на всякий случай (векторные схемы встречаются).
_ALLOWED_PLAN_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".svg")

# Замена небезопасных для имени файла символов в ods_id (напр. "65136/1/1.4.3").
_ODS_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")


# ── payload parsing ───────────────────────────────────────────────────────────


def extract_flat_plans(raw_payload: dict[str, Any]) -> list[dict[str, Any]]:
    """Извлечь plan_image_url из flat-table payload.

    Текущий статус: plan image URL в kn-API flat-table payload ОТСУТСТВУЕТ
    (audit 2026-05-17). Функция зарезервирована для будущей интеграции:
    - если DOM.РФ добавит поле в portal/table,
    - или при подключении SSR-scraper каталога (22d-track).

    Формат входных данных (portal/table response):
    {
        "externalId": 65136,
        "entrances": [
            {
                "entranceNumber": 1,
                "floors": [
                    {
                        "floorNumber": 4,
                        "flats": [
                            {
                                "odsId": "65136/1/1.4.3",
                                "elemId": "d8c7a8103f26c52e427ace5a996706ba",
                                "totalArea": 36.22,
                                ...
                                # планировка НЕ содержится в этом payload
                            }
                        ]
                    }
                ]
            }
        ]
    }

    Возвращает список dict с ключами:
        ods_id          str  — идентификатор квартиры
        plan_image_url  str  — URL картинки планировки
        obj_id          int  — внешний ID объекта

    При отсутствии нужного поля возвращает пустой список.
    """
    obj_id = raw_payload.get("externalId")
    plans: list[dict[str, Any]] = []

    for entrance in raw_payload.get("entrances") or []:
        for floor_data in entrance.get("floors") or []:
            for flat in floor_data.get("flats") or []:
                ods_id = flat.get("odsId")
                if not ods_id:
                    continue

                # Пробуем несколько возможных имён поля — на случай если API
                # в будущем добавит это поле под одним из вариантов.
                plan_url = (
                    flat.get("planImageUrl")
                    or flat.get("layoutImageUrl")
                    or flat.get("planUrl")
                    or flat.get("layoutUrl")
                    or flat.get("imageUrl")
                )
                if not plan_url:
                    continue

                plans.append(
                    {
                        "ods_id": ods_id,
                        "obj_id": obj_id,
                        "plan_image_url": plan_url,
                    }
                )

    if not plans:
        logger.debug(
            "extract_flat_plans: obj_id=%s — plan_image_url не найден в payload "
            "(ожидаемо: API не содержит это поле, нужен SSR-scraper каталога)",
            obj_id,
        )

    return plans


# ── DB write ──────────────────────────────────────────────────────────────────


_UPSERT_FLAT_PLAN_SQL = text(
    """
    INSERT INTO domrf_kn_flat_plans (
        ods_id, obj_id, plan_image_url, local_path,
        width_px, height_px, size_bytes, downloaded_at,
        snapshot_date, scraped_at
    ) VALUES (
        :ods_id, :obj_id, :plan_image_url, :local_path,
        :width_px, :height_px, :size_bytes, :downloaded_at,
        :snapshot_date, NOW()
    )
    ON CONFLICT (ods_id) DO UPDATE SET
        plan_image_url = EXCLUDED.plan_image_url,
        obj_id         = EXCLUDED.obj_id,
        snapshot_date  = EXCLUDED.snapshot_date,
        scraped_at     = NOW(),
        local_path     = COALESCE(domrf_kn_flat_plans.local_path, EXCLUDED.local_path),
        downloaded_at  = COALESCE(domrf_kn_flat_plans.downloaded_at, EXCLUDED.downloaded_at),
        width_px       = COALESCE(domrf_kn_flat_plans.width_px, EXCLUDED.width_px),
        height_px      = COALESCE(domrf_kn_flat_plans.height_px, EXCLUDED.height_px),
        size_bytes     = COALESCE(domrf_kn_flat_plans.size_bytes, EXCLUDED.size_bytes)
    """
)


def upsert_flat_plans(
    db: Session,
    obj_id: int,
    plans: list[dict[str, Any]],
    snapshot_date: date,
) -> int:
    """UPSERT планировок квартир в domrf_kn_flat_plans.

    Для каждой строки из `plans` (список dict от `extract_flat_plans` или
    SSR-scraper) выполняет INSERT ON CONFLICT UPDATE.

    COALESCE-логика: уже скачанные файлы (local_path, downloaded_at,
    width_px, height_px, size_bytes) НЕ перезаписываются — только
    обновляются plan_image_url и snapshot_date.

    Args:
        db:            SQLAlchemy Session.
        obj_id:        ID объекта DOM.РФ (для логирования).
        plans:         Список dict с ключами ods_id, plan_image_url [, obj_id].
        snapshot_date: Дата snapshot, в котором найден URL.

    Returns:
        Количество успешно обработанных строк.
    """
    inserted = 0
    for plan in plans:
        ods_id = plan.get("ods_id")
        plan_url = plan.get("plan_image_url")
        if not ods_id or not plan_url:
            logger.warning(
                "upsert_flat_plans obj=%s: пропущена запись без ods_id/plan_image_url: %s",
                obj_id,
                plan,
            )
            continue
        try:
            with db.begin_nested():
                db.execute(
                    _UPSERT_FLAT_PLAN_SQL,
                    {
                        "ods_id": ods_id,
                        "obj_id": plan.get("obj_id") or obj_id,
                        "plan_image_url": plan_url,
                        "local_path": plan.get("local_path"),
                        "width_px": plan.get("width_px"),
                        "height_px": plan.get("height_px"),
                        "size_bytes": plan.get("size_bytes"),
                        "downloaded_at": plan.get("downloaded_at"),
                        "snapshot_date": snapshot_date,
                    },
                )
            inserted += 1
        except Exception as e:
            logger.warning("upsert_flat_plans obj=%s ods_id=%s failed: %s", obj_id, ods_id, e)
    if inserted:
        logger.info("upsert_flat_plans obj=%s: %d планировок записано", obj_id, inserted)
    return inserted


# ── image download ────────────────────────────────────────────────────────────


def _safe_ods_filename(ods_id: str) -> str:
    """Превратить ods_id (напр. '65136/1/1.4.3') в безопасное имя файла.

    Слэши и прочие небезопасные символы → '_'. Результат используется как
    basename в {base_dir}/{obj_id}/{safe}.{ext}.
    """
    return _ODS_UNSAFE_RE.sub("_", ods_id).strip("_") or "plan"


def _ext_from_url(url: str) -> str:
    """Определить расширение файла по URL планировки. Default .png.

    DOM.РФ отдаёт планы преимущественно PNG; расширение берём из path, если
    оно в белом списке, иначе .png (file-API URL /api/ext/file/... часто без
    расширения — контент-тип уточнится по факту, но png — безопасный дефолт).
    """
    path = urlsplit(url).path.lower()
    for ext in _ALLOWED_PLAN_EXTS:
        if path.endswith(ext):
            return ".jpg" if ext == ".jpeg" else ext
    return ".png"


def _image_dims(data: bytes) -> tuple[int | None, int | None]:
    """Получить (width_px, height_px) картинки через Pillow. None при ошибке.

    Pillow (PIL) уже зависимость репо (pyproject: pillow>=10.4.0, используется в
    app.services.photos.thumbs). SVG Pillow не парсит — вернёт (None, None),
    это ОК (размеры для векторных планов необязательны).
    """
    try:
        from PIL import Image

        with Image.open(io.BytesIO(data)) as im:
            return int(im.width), int(im.height)
    except Exception as exc:
        logger.debug("flat plan image dims parse failed: %s", exc)
        return None, None


async def download_plan_image(
    session: BrowserSession,
    plan_image_url: str,
    ods_id: str,
    obj_id: int,
    base_dir: Path | None = None,
) -> dict[str, Any] | None:
    """Скачать бинарник планировки квартиры и вернуть метаданные для upsert.

    Реиспользует BrowserSession.download_binary — тот же паттерн что download_photos
    в domrf_kn.py: тянет файл через browser context (те же cookies / TLS-fingerprint,
    что прошли WAF), с лёгким throttle (jitter 200–500 мс встроен в download_binary).

    Файл сохраняется в {base_dir}/{obj_id}/{safe_ods_id}.{ext}. Если файл уже есть
    на диске — повторно НЕ качаем (idempotent, экономит WAF-бюджет), но всё равно
    возвращаем метаданные (размеры/размер читаются с диска).

    Args:
        session:        Активный BrowserSession (bootstrapped + warm_up).
        plan_image_url: Абсолютный URL картинки планировки.
        ods_id:         Идентификатор квартиры (для имени файла).
        obj_id:         ID объекта DOM.РФ (подпапка).
        base_dir:       Корень для сохранения (None = FLAT_PLANS_DIR_DEFAULT).

    Returns:
        dict с ключами local_path, width_px, height_px, size_bytes, downloaded_at —
        для передачи в upsert_flat_plans. None при ошибке скачивания.
    """
    root = base_dir or FLAT_PLANS_DIR_DEFAULT
    obj_dir = root / str(obj_id)
    ext = _ext_from_url(plan_image_url)
    local = obj_dir / f"{_safe_ods_filename(ods_id)}{ext}"

    # Idempotent: уже скачан — читаем метаданные с диска, не жжём WAF-запрос.
    if local.exists() and local.stat().st_size > 0:
        data = local.read_bytes()
        width_px, height_px = _image_dims(data)
        logger.debug("flat plan already on disk ods_id=%s path=%s", ods_id, local)
        return {
            "local_path": str(local),
            "width_px": width_px,
            "height_px": height_px,
            "size_bytes": local.stat().st_size,
            "downloaded_at": datetime.now(UTC),
        }

    try:
        data = await session.download_binary(plan_image_url)
    except Exception as exc:
        logger.warning(
            "download_plan_image failed ods_id=%s obj_id=%s url=%s: %s",
            ods_id,
            obj_id,
            plan_image_url,
            exc,
        )
        return None

    if not data:
        logger.warning("download_plan_image empty body ods_id=%s url=%s", ods_id, plan_image_url)
        return None

    obj_dir.mkdir(parents=True, exist_ok=True)
    try:
        local.write_bytes(data)
    except OSError as exc:
        logger.warning("download_plan_image write failed ods_id=%s path=%s: %s", ods_id, local, exc)
        return None

    width_px, height_px = _image_dims(data)
    logger.info(
        "download_plan_image ok ods_id=%s obj_id=%s bytes=%d dims=%sx%s",
        ods_id,
        obj_id,
        len(data),
        width_px,
        height_px,
    )
    return {
        "local_path": str(local),
        "width_px": width_px,
        "height_px": height_px,
        "size_bytes": len(data),
        "downloaded_at": datetime.now(UTC),
    }
