"""Парсер ТЭП из пояснительных записок ППТ/ПМТ (#1085, GG-форсайт #1067).

Источник: PDF-документы планировочных проектов (ППТ/ПМТ) Екатеринбурга.
Геометрия ППТ/ПМТ уже в БД (planning_projects, 1170 объектов, WFS).

Три целевые таблицы:
    Табл.11 — Баланс территории по зонам (га, %);
    Табл.12 — Основные технико-экономические показатели;
    Табл.13 — Очередность строительства (зона / состав / финансирование).

Особенности:
    - Таблицы обнаруживаются по заголовку-caption через extract_text() на каждой странице.
    - Каждая таблица может занимать несколько страниц (особенно Табл.12 и 13).
    - Структура ППТ разнится — парсер работает best-effort:
      не найдена таблица → соответствующее поле = [] (не исключение).
    - Кириллица: используем ТОЛЬКО pdfplumber (pdftotext/poppler даёт mojibake).
    - Нормализация числовых значений: «15,41 га», «(42,61 %)» → float.
"""

from __future__ import annotations

import io
import logging
import re
from dataclasses import dataclass, field
from typing import Any

import pdfplumber

logger = logging.getLogger(__name__)

# ── Нормализация значений ────────────────────────────────────────────────────

_RE_NUM = re.compile(r"[\d\s]+(?:[.,]\d+)?")


def _norm_value(raw: str | None) -> str:
    """Очищаем ячейку: убираем лишние пробелы, переносы, управляющие символы."""
    if raw is None:
        return ""
    # Убираем управляющие глифы (например  — «»)
    text = re.sub(r"[-]", "", raw)
    # Нормализуем переносы строк внутри ячейки → пробел
    text = re.sub(r"\s*\n\s*", " ", text)
    return text.strip()


def _try_float(value: str) -> float | None:
    """Пытаемся превратить строку вида '15,41 га (42,61 %)' в float первого числа."""
    m = re.search(r"(\d[\d\s]*(?:[.,]\d+)?)", value)
    if not m:
        return None
    num_str = m.group(1).replace(" ", "").replace(",", ".")
    try:
        return float(num_str)
    except ValueError:
        return None


# ── Обнаружение таблиц ───────────────────────────────────────────────────────

# Паттерны для определения номера таблицы в тексте страницы.
# Ищем «Таблица 11», «Таблица 12», «Таблица 13» (латинская «а» вместо кириллической
# тоже встречается в некоторых PDF — поэтому без локали).
_TABLE_CAPTION_RE = {
    11: re.compile(r"Таблица\s+11\b", re.IGNORECASE),
    12: re.compile(r"Таблица\s+12\b", re.IGNORECASE),
    13: re.compile(r"Таблица\s+13\b", re.IGNORECASE),
}


def _page_contains_table(text: str, table_no: int) -> bool:
    """Возвращает True если текст страницы содержит заголовок таблицы N.

    Требуем явный caption «Таблица N» — hint-только режим (оглавление, перекрёстные ссылки)
    даёт false positive и подавляется. Если caption присутствует — достаточно.
    """
    cap = _TABLE_CAPTION_RE[table_no]
    return bool(cap.search(text))


# ── Парсинг конкретных таблиц ────────────────────────────────────────────────


def _parse_table11(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
    """Баланс территории по зонам (Табл.11).

    Структура: № | Наименование зоны | Сущ., га (%) | Проект, га (%)
    Заголовок (первые 2 строки) пропускается по признаку: №/None в col[0].

    Post-фильтр (#1136 follow-up #1133): отбрасываем строки с пустым
    ``zone_name`` (преамбульные артефакты merged-cells на образце ppt2018_22823
    давали 2 «фантомные» строки без названия зоны при 12 реальных). «Итого»-
    строка остаётся (распознаётся по тексту, не по zone_name).
    """
    result: list[dict[str, Any]] = []
    header_skipped = False

    for row in raw_rows:
        if not row:
            continue
        no = _norm_value(row[0] if len(row) > 0 else None)
        zone = _norm_value(row[1] if len(row) > 1 else None)
        existing = _norm_value(row[2] if len(row) > 2 else None)
        project = _norm_value(row[3] if len(row) > 3 else None)

        # Строка-заголовок: col[0] в {№, None, ''} и col[1] содержит
        # «Наименование» или «Площадь» — пропускаем.
        if not header_skipped:
            zone_lo = zone.lower()
            if no in ("", "№", None) and ("наименование" in zone_lo or "площадь" in zone_lo):
                header_skipped = True
                continue
            if no in ("", "№", None) and not zone:
                continue

        # Пропускаем строки без номера — вложенные подзаголовки.
        # Итоговую строку включаем (распознаём по «Итого»).
        is_total = "итого" in (no + zone).lower()

        if not no and not is_total:
            continue

        # Post-фильтр (#1136): отбрасываем строки без осмысленного zone_name
        # (преамбульный шум merged-cells). Исключение — Итого-строка: у неё
        # zone_name может быть пуст (значение часто в col[0]).
        if not zone and not is_total:
            continue

        record: dict[str, Any] = {
            "no": no,
            "zone_name": zone,
            "existing_ha": _norm_value(existing),
            "existing_ha_num": _try_float(existing),
            "project_ha": _norm_value(project),
            "project_ha_num": _try_float(project),
        }
        if is_total:
            record["is_total"] = True
        result.append(record)

    return result


def _parse_table12(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
    """Основные ТЭП (Табл.12).

    Структура: № | Наименование показателей | Единица измерения |
    Современное состояние | Расчётный срок.
    Таблица может содержать дополнительный столбец «I этап»
    (на страницах 112-113 этапы разнесены).
    """
    result: list[dict[str, Any]] = []
    header_skipped = False

    for row in raw_rows:
        if not row:
            continue
        # Чистим все ячейки
        cells = [_norm_value(c) for c in row]
        if not any(cells):
            continue

        no = cells[0] if cells else ""
        name = cells[1] if len(cells) > 1 else ""
        unit = cells[2] if len(cells) > 2 else ""
        current = cells[3] if len(cells) > 3 else ""
        target = cells[4] if len(cells) > 4 else ""

        # Пропуск строки-заголовка таблицы
        if not header_skipped:
            if "наименование" in name.lower() or "показател" in name.lower():
                header_skipped = True
                continue
            if no in ("", "№") and ("единица" in unit.lower() or "измерени" in unit.lower()):
                header_skipped = True
                continue

        # Пропускаем полностью пустые строки
        if not (no or name or current or target):
            continue

        record: dict[str, Any] = {
            "no": no,
            "indicator": name,
            "unit": unit,
            "current": current,
            "target": target,
        }
        # Если 6 колонок — есть этапная разбивка (I этап / II этап)
        if len(cells) > 5:
            record["stage1"] = cells[4]
            record["target"] = cells[5]

        result.append(record)

    return result


def _parse_table13(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
    """Очередность строительства (Табл.13).

    Структура сложная (20 колонок из-за merged cells):
    Нас интересуют: фаза (col[0]), состав объекта (col[1..2]), зона (col[13]),
    площадь (col[15]), финансирование (col[17]), программа (col[19]).
    Извлекаем только строки с непустым полем «зона» или «состав объекта».

    Post-фильтры (#1136 follow-up #1133): на образце ppt2018_22823 merged-cells
    давали ~364 строк continuation-шума при ~30-40 реальных. Применяем:

    1. Дедуп по ключу (phase, composition, zone, area_ha_norm) — повторы
       continuation-строк merged-cells.
    2. Отбрасываем «короткий-без-зоны» шум: composition < 4 символов И zone пуст
       (артефакты разрыва ячеек).
    3. Отбрасываем строки, у которых composition — одна цифра/один знак
       (нумерация осколков merged-cells).
    """
    rows_raw: list[dict[str, Any]] = []
    current_phase: str = ""
    header_skipped = False

    for row in raw_rows:
        if not row:
            continue
        cells = [_norm_value(c) for c in row]

        # Пропуск заголовка (первые 2 строки)
        if not header_skipped:
            first_meaningful = next((c for c in cells if c), "")
            if "номер" in first_meaningful.lower() or "состав" in first_meaningful.lower():
                header_skipped = True
                continue
            # Строка с «1 2 4 5 6 7» (номера колонок)
            if first_meaningful in ("1", ""):
                continue

        # Определяем фазу/этап строительства (ячейка 0, начинается с «I», «II», «III»).
        col0 = cells[0] if cells else ""
        if re.match(r"^(I{1,3}V?|IV|V)\s*(этап|очередь)?", col0, re.IGNORECASE) and col0:
            current_phase = col0

        # Состав объекта — из колонок 1 или 3 (после слияния).
        # При 20-колоночной таблице ищем непустой текст в диапазоне 1..5.
        composition = ""
        for ci in range(1, min(6, len(cells))):
            if cells[ci]:
                composition = cells[ci]
                break

        # Зона планируемого размещения.
        zone = ""
        area_ha_str = ""
        funding = ""
        program = ""

        if len(cells) >= 14:
            zone = cells[13]
        if len(cells) >= 16:
            area_ha_str = cells[15]
        if len(cells) >= 18:
            funding = cells[17]
        if len(cells) >= 20:
            program = cells[19]

        # Пропускаем строки без осмысленного содержания
        if not (composition or zone):
            continue
        # Пропускаем строки-секционные заголовки (нет зоны, состав = подзаголовок раздела)
        if not zone and "объект" in composition.lower() and len(composition) < 50:
            continue

        rows_raw.append(
            {
                "phase": current_phase,
                "composition": composition,
                "zone": zone,
                "area_ha": area_ha_str,
                "area_ha_num": _try_float(area_ha_str),
                "funding": funding,
                "program": program,
            }
        )

    # Post-фильтр (#1136): шум merged-cells.
    # 1. Короткий-без-зоны: composition короткий (< 4 симв.) И zone пуст —
    #    типично осколок ячейки («1», «-», «—»).
    # 2. Composition — одна цифра/знак-пунктуация: «1», «2», «-» и т.п.
    def _is_noise(rec: dict[str, Any]) -> bool:
        comp = rec["composition"]
        zone = rec["zone"]
        # Короткий composition без зоны — осколок merged-cell.
        if not zone and len(comp) < 4:
            return True
        # Composition — только цифра/знак (один знак, или комбинация цифр/пунктуации).
        if comp and not re.search(r"[A-Za-zА-Яа-я]", comp) and len(comp) < 6:
            return True
        return False

    rows_clean = [r for r in rows_raw if not _is_noise(r)]

    # Post-фильтр (#1136): дедуп по (phase, composition, zone, area_ha_norm).
    # area_ha сравниваем по числовому значению (если есть) — иначе по строке.
    seen: set[tuple[str, str, str, str]] = set()
    result: list[dict[str, Any]] = []
    for rec in rows_clean:
        area_key = (
            f"{rec['area_ha_num']:.4f}" if rec["area_ha_num"] is not None else rec["area_ha"]
        )
        key = (rec["phase"], rec["composition"], rec["zone"], area_key)
        if key in seen:
            continue
        seen.add(key)
        result.append(rec)

    return result


# ── Сборка сырых строк со всех страниц блока таблицы ────────────────────────


def _collect_table_rows(
    pages: list[pdfplumber.page.Page],
    start_page_idx: int,
    table_no: int,
    next_table_no: int | None,
) -> tuple[list[list[str | None]], list[dict[str, Any]]]:
    """Собирает строки таблицы N начиная со страницы start_page_idx.

    Останавливается когда встречает заголовок следующей таблицы (next_table_no)
    или «Рис.» / другой раздел.

    Returns:
        (all_rows, raw_table_records) — строки для парсинга и сырые данные для
        поля raw_tables.
    """
    all_rows: list[list[str | None]] = []
    raw_records: list[dict[str, Any]] = []
    found_start = False

    for pi in range(start_page_idx, len(pages)):
        page = pages[pi]
        page_text = page.extract_text() or ""

        # Обнаружение начала нашей таблицы
        if not found_start:
            if _page_contains_table(page_text, table_no):
                found_start = True
            else:
                continue

        # Стоп-условие: начался следующий раздел/таблица
        if found_start and pi > start_page_idx:
            if next_table_no and _page_contains_table(page_text, next_table_no):
                break

        # Извлекаем таблицы со страницы
        page_tables = page.extract_tables()
        for ti, tbl in enumerate(page_tables):
            if not tbl:
                continue
            # Берём только таблицы с ≥2 строками и ≥2 колонками
            if len(tbl) < 2 or (tbl[0] and len(tbl[0]) < 2):
                continue

            # На первой странице таблицы пропускаем маленькие таблицы (заголовки/рамки)
            # — они идут ДО основной таблицы данных.
            if pi == start_page_idx and len(tbl) < 4:
                continue

            all_rows.extend(tbl)
            raw_records.append(
                {
                    "page": pi + 1,  # 1-based для отладки
                    "table_index": ti,
                    "rows": [[_norm_value(c) for c in row] for row in tbl[:10]],  # cap 10 rows
                }
            )

    return all_rows, raw_records


# ── Публичный API ────────────────────────────────────────────────────────────


@dataclass
class PptTep:
    """Результат парсинга пояснительной записки ППТ/ПМТ.

    Каждое поле — list[dict] с нормализованными строками таблицы.
    Пустой список означает «таблица не найдена» (best-effort, не ошибка).
    """

    zone_balance: list[dict[str, Any]] = field(default_factory=list)
    tep: list[dict[str, Any]] = field(default_factory=list)
    phasing: list[dict[str, Any]] = field(default_factory=list)
    raw_tables: list[dict[str, Any]] = field(default_factory=list)


def parse_ppt_tep(pdf_bytes: bytes) -> PptTep:
    """Парсит PDF пояснительной записки ППТ/ПМТ → ТЭП (Табл.11/12/13).

    Чистая функция от bytes; сеть/fetch — вне этой функции.
    Graceful при отсутствии таблиц: поле = [] вместо исключения.

    Args:
        pdf_bytes: сырые байты PDF-документа.

    Returns:
        PptTep с заполненными полями zone_balance / tep / phasing / raw_tables.
    """
    result = PptTep()

    try:
        with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
            pages = pdf.pages
            n_pages = len(pages)
            logger.info("parse_ppt_tep: opened PDF, %d pages", n_pages)

            # Находим страницы-начала Табл.11/12/13
            page_11 = page_12 = page_13 = None
            for pi, page in enumerate(pages):
                text = page.extract_text() or ""
                if page_11 is None and _page_contains_table(text, 11):
                    page_11 = pi
                if page_12 is None and _page_contains_table(text, 12):
                    page_12 = pi
                if page_13 is None and _page_contains_table(text, 13):
                    page_13 = pi

            logger.info(
                "parse_ppt_tep: Табл.11 на стр.%s, Табл.12 на стр.%s, Табл.13 на стр.%s",
                page_11 and page_11 + 1,
                page_12 and page_12 + 1,
                page_13 and page_13 + 1,
            )

            # Табл.11 — Баланс территории
            if page_11 is not None:
                rows_11, raw_11 = _collect_table_rows(pages, page_11, 11, 12)
                result.zone_balance = _parse_table11(rows_11)
                result.raw_tables.extend(raw_11)
                logger.info("parse_ppt_tep: Табл.11 → %d строк", len(result.zone_balance))
            else:
                logger.warning("parse_ppt_tep: Таблица 11 не найдена в PDF")

            # Табл.12 — ТЭП
            if page_12 is not None:
                rows_12, raw_12 = _collect_table_rows(pages, page_12, 12, 13)
                result.tep = _parse_table12(rows_12)
                result.raw_tables.extend(raw_12)
                logger.info("parse_ppt_tep: Табл.12 → %d строк", len(result.tep))
            else:
                logger.warning("parse_ppt_tep: Таблица 12 не найдена в PDF")

            # Табл.13 — Очередность строительства
            if page_13 is not None:
                rows_13, raw_13 = _collect_table_rows(pages, page_13, 13, None)
                result.phasing = _parse_table13(rows_13)
                result.raw_tables.extend(raw_13)
                logger.info("parse_ppt_tep: Табл.13 → %d строк", len(result.phasing))
            else:
                logger.warning("parse_ppt_tep: Таблица 13 не найдена в PDF")

    except Exception as exc:
        logger.error("parse_ppt_tep: ошибка парсинга PDF: %s", exc)
        raise

    return result
