"""ДДУ price indicator — ARN-framework applied to primary market (Issue #99).

Reads ``mv_ddu_price_indicator`` (migration 152) and serves an ARN-mirror
response: per-quarter price indicator of the PRIMARY market (новостройки/ДДУ)
for Свердловская область (region 66), broken down by area bucket.

This is a deterministic statistical indicator (median price/m² ratios), not a
forecast. See the migration header for the full MVP-scope rationale; the short
version of the honest simplifications versus the full ARN matrix:

- Granularity = subject (region 66), NOT cad_quarter — primary-market ДДУ rows
  are far too sparse per cad_quarter after the 2025 rosreestr aggregation change.
- period_type = 'Q' only (source publishes quarterly; no monthly granularity).
- Window starts 2025-Q2 (hard data-regime break at 2025-Q1).
- calc_method 1 (basis) and 2 (previous) only. Method 3 (year-ago) is BLOCKED:
  there is no comparable pre-2025-Q2 primary-market data.

The endpoint accepts the same request body shape as the ARN API (indicators,
calculationMethod, periodFrom/To, areaRanges, federalSubject) so the frontend
can reuse one UI pattern. Unsupported ARN options are reported in a ``notes``
list rather than silently ignored.
"""

from __future__ import annotations

import logging
import re
from decimal import Decimal
from typing import Any

from sqlalchemy import text
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.orm import Session

logger = logging.getLogger(__name__)

# ARN area-range labels (poquartirnaya area, m²). Index = area_bucket id.
_BUCKET_LABELS: dict[int, str] = {
    0: "Все площади",
    1: "0–25 м²",
    2: "25–40 м²",
    3: "40–60 м²",
    4: "60–80 м²",
    5: "80–100 м²",
    6: "100+ м²",
}

# Calc methods supported by the MVP. 3 (year-ago) is intentionally absent.
_CALC_BASIS = 1
_CALC_PREVIOUS = 2
_SUPPORTED_METHODS = (_CALC_BASIS, _CALC_PREVIOUS)

# Only Свердловская область (66) and the Уральский federal district are backed
# by data today; other ARN federalSubject / federalDistrict values are reported
# as unsupported.
_SUPPORTED_SUBJECT = "66"

# ARN period_value format (matches mv_ddu_price_indicator.period_value, e.g.
# '2026-Q1'). Used to reject malformed bounds whose lexicographic comparison
# against well-formed period_value would silently drop rows.
_PERIOD_RE = re.compile(r"^\d{4}-Q[1-4]$")


def _f(value: Any) -> float | None:
    if value is None:
        return None
    if isinstance(value, Decimal):
        return float(value)
    return float(value)


def _normalize_subjects(federal_subject: list[str] | None) -> tuple[bool, list[str]]:
    """Return (subject_supported, unsupported_subjects)."""
    if not federal_subject:
        return True, []
    unsupported = [s for s in federal_subject if str(s) != _SUPPORTED_SUBJECT]
    supported = any(str(s) == _SUPPORTED_SUBJECT for s in federal_subject)
    return supported, unsupported


def get_ddu_indicator(
    db: Session,
    *,
    calculation_method: int = _CALC_PREVIOUS,
    period_from: str | None = None,
    period_to: str | None = None,
    area_ranges: list[int] | None = None,
    federal_subject: list[str] | None = None,
    indicators: list[str] | None = None,
) -> dict[str, Any]:
    """ARN-mirror response for the primary-market ДДУ price indicator.

    Args:
        db: SQLAlchemy session (sync).
        calculation_method: 1 = basis (vs first period), 2 = previous quarter.
        period_from / period_to: inclusive ARN-style bounds, e.g. '2025-Q2'..'2026-Q1'.
            None = no bound on that side.
        area_ranges: ARN area-bucket ids to include (1..6); 0 = all-area headline.
            None / empty → all buckets present in the MV.
        federal_subject: ARN subject codes. Only '66' is backed by data.
        indicators: ARN indicator types (M/Q/H/Y). Only 'Q' is supported.

    Returns:
        ``{"meta": {...}, "table": [...], "graph": [...], "notes": [...]}``.
        ``table`` rows carry both the chosen-method ``index`` and the raw median;
        ``graph`` is the headline (bucket 0) series for charting.
    """
    notes: list[str] = []

    method = calculation_method if calculation_method in _SUPPORTED_METHODS else _CALC_PREVIOUS
    if calculation_method not in _SUPPORTED_METHODS:
        notes.append(
            f"calculationMethod={calculation_method} не поддержан "
            f"(MVP: 1=базисный, 2=предыдущий; 3=годом-ранее недоступен — нет "
            f"сопоставимых данных первички до 2025-Q2). Использован method=2 (предыдущий)."
        )

    if indicators:
        unsupported_ind = [i for i in indicators if str(i).upper() != "Q"]
        if unsupported_ind:
            notes.append(
                f"indicators={unsupported_ind} не поддержаны (источник rosreestr "
                f"публикует данные поквартально; доступен только 'Q')."
            )

    subject_ok, unsupported_subjects = _normalize_subjects(federal_subject)
    if unsupported_subjects:
        notes.append(
            f"federalSubject={unsupported_subjects} вне покрытия "
            f"(данные первички только по 66 — Свердловская обл.)."
        )
    if not subject_ok:
        # Asked exclusively for unsupported subjects → empty result, explained.
        return {
            "meta": {
                "market": "primary_ddu",
                "region_code": int(_SUPPORTED_SUBJECT),
                "calculation_method": method,
                "period_type": "Q",
            },
            "table": [],
            "graph": [],
            "notes": notes,
        }

    index_col = "index_basis" if method == _CALC_BASIS else "index_previous"

    # area_ranges filter (optional). Validate ids to a safe int list — never
    # interpolate into SQL; bound via ANY(CAST(:buckets AS int[])).
    bucket_filter = ""
    params: dict[str, Any] = {}
    if area_ranges:
        clean_buckets = sorted({int(b) for b in area_ranges if 0 <= int(b) <= 6})
        if clean_buckets:
            bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
            params["buckets"] = clean_buckets
        else:
            # Client narrowed by area buckets but every id is out of range (0..6)
            # → honour the explicit narrowing with an empty result, never silently
            # widen back to all buckets. Mirrors the `not subject_ok` branch above.
            notes.append(
                f"areaRanges={area_ranges} вне диапазона 0..6 "
                f"(0=все площади, 1..6=диапазоны м²) — нет подходящих площадей."
            )
            return {
                "meta": {
                    "market": "primary_ddu",
                    "region_code": int(_SUPPORTED_SUBJECT),
                    "calculation_method": method,
                    "period_type": "Q",
                },
                "table": [],
                "graph": [],
                "notes": notes,
            }

    # Validate period bounds against the documented ARN 'YYYY-QN' format before
    # binding them. period_value is compared lexicographically (it is text); a
    # malformed bound ('foo', '2026') would silently drop rows, so drop the bad
    # bound and explain it in notes (this endpoint's convention is notes, not 422).
    if period_from and not _PERIOD_RE.match(period_from):
        notes.append(
            f"periodFrom={period_from!r} не в формате 'YYYY-QN' (напр. '2025-Q2') "
            f"— граница проигнорирована."
        )
        period_from = None
    if period_to and not _PERIOD_RE.match(period_to):
        notes.append(
            f"periodTo={period_to!r} не в формате 'YYYY-QN' (напр. '2026-Q1') "
            f"— граница проигнорирована."
        )
        period_to = None
    # Inverted range (from > to) yields an empty table with no signal otherwise.
    # Lexicographic comparison is correct here because the format is zero-padded
    # 'YYYY-QN'.
    if period_from and period_to and period_from > period_to:
        notes.append(
            f"periodFrom={period_from!r} > periodTo={period_to!r} — границы "
            f"перепутаны (диапазон инвертирован), результат пуст."
        )

    period_filter = ""
    if period_from:
        period_filter += " AND period_value >= :period_from"
        params["period_from"] = period_from
    if period_to:
        period_filter += " AND period_value <= :period_to"
        params["period_to"] = period_to

    try:
        rows = (
            db.execute(
                text(
                    f"""
                    SELECT
                        area_bucket,
                        period_value,
                        period_type,
                        deals_count,
                        median_price_per_m2,
                        index_basis,
                        index_previous,
                        prev_period_value,
                        {index_col} AS chosen_index
                      FROM mv_ddu_price_indicator
                     WHERE 1 = 1
                       {bucket_filter}
                       {period_filter}
                     ORDER BY area_bucket, period_value
                    """
                ),
                params,
            )
            .mappings()
            .all()
        )
    except (ProgrammingError, OperationalError):
        # Most likely the MV does not exist yet (migration 152 not applied).
        # A missing relation is SQLSTATE 42P01 (UndefinedTable) → ProgrammingError;
        # OperationalError is kept for connection-level failures.
        logger.exception("ddu_indicator: query failed (mv_ddu_price_indicator missing?)")
        raise

    table: list[dict[str, Any]] = []
    graph: list[dict[str, Any]] = []
    for r in rows:
        bucket = int(r["area_bucket"])
        item = {
            "area_bucket": bucket,
            "area_label": _BUCKET_LABELS.get(bucket, str(bucket)),
            "period_value": r["period_value"],
            "period_type": r["period_type"],
            "deals_count": int(r["deals_count"]),
            "median_price_per_m2": _f(r["median_price_per_m2"]),
            "index": _f(r["chosen_index"]),
            "index_basis": _f(r["index_basis"]),
            "index_previous": _f(r["index_previous"]),
            "prev_period_value": r["prev_period_value"],
        }
        table.append(item)
        if bucket == 0:
            graph.append(
                {
                    "period_value": r["period_value"],
                    "index": _f(r["chosen_index"]),
                    "median_price_per_m2": _f(r["median_price_per_m2"]),
                    "deals_count": int(r["deals_count"]),
                }
            )

    logger.info(
        "ddu_indicator: method=%d buckets=%s period=%s..%s -> %d rows",
        method,
        params.get("buckets", "all"),
        period_from or "*",
        period_to or "*",
        len(table),
    )

    return {
        "meta": {
            "market": "primary_ddu",
            "region_code": int(_SUPPORTED_SUBJECT),
            "calculation_method": method,
            "period_type": "Q",
            "source": "rosreestr_deals (ДДУ, realestate_type_code=002001003000)",
            "arn_comparison": {
                "note": (
                    "ARN/НСПД считает вторичку; здесь первичка (новостройки). "
                    "Для новостроек рост индекса ≠ инфляция — это сдвиг ассортимента + цен."
                ),
                "ekb_q1_2026_arn_secondary": 1.03,
            },
        },
        "table": table,
        "graph": graph,
        "notes": notes,
    }


def refresh_ddu_price_indicator(db: Session, *, concurrently: bool = True) -> int:
    """REFRESH MATERIALIZED VIEW mv_ddu_price_indicator.

    Mirrors refresh_layout_velocity(): CONCURRENTLY by default (needs the unique
    index from migration 152), with a one-shot fallback to non-concurrent if the
    MV is not yet populated. Not wired into Celery beat in this PR — manual /
    admin invocation, like layout_velocity_refresh.

    Returns the row count after refresh (observability).
    """
    try:
        if concurrently:
            db.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY mv_ddu_price_indicator"))
        else:
            db.execute(text("REFRESH MATERIALIZED VIEW mv_ddu_price_indicator"))
        db.commit()
    except OperationalError as e:
        if concurrently and "cannot refresh materialized view" in str(e).lower():
            logger.warning(
                "ddu_indicator CONCURRENTLY failed (MV not populated), falling back"
            )
            db.rollback()
            db.execute(text("REFRESH MATERIALIZED VIEW mv_ddu_price_indicator"))
            db.commit()
        else:
            raise
    row = db.execute(text("SELECT COUNT(*) FROM mv_ddu_price_indicator")).first()
    count = int(row[0]) if row else 0
    logger.info("mv_ddu_price_indicator refreshed: %d rows", count)
    return count
