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

Синтетические тесты: pdfplumber мокируется; реальный PDF-тест — отдельный
класс с @pytest.mark.skipif (skip в CI, запуск вручную).
"""

from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from app.services.scrapers.ekb_ppt_tep_parser import (
    PptTep,
    _parse_table11,
    _parse_table12,
    _parse_table13,
    parse_ppt_tep,
)

# ── Вспомогательные фикстуры ─────────────────────────────────────────────────

# Минимальные данные Табл.11 (баланс территории)
_TABLE11_ROWS: list[list[str | None]] = [
    ["№", "Наименование зоны", "Площадь", None],
    [None, None, "Сущ., га (%)", "Проект, га\n(%)"],
    ["1", "Многофункциональная зона", "15,41 га\n(42,61 %)", "11,34 га\n(31,36 %)"],
    ["2", "Зона объектов здравоохранения", "0,24 га\n(0,66 %)", "0,25 га\n(0,69 %)"],
    ["Итого:", None, "36,16 га\n(100 %)", "36,16 га\n(100 %)"],
]

# Минимальные данные Табл.12 (ТЭП)
_TABLE12_ROWS: list[list[str | None]] = [
    [
        "№",
        "Наименование показателей",
        "Единица\nизмерения",
        "Современное\nсостояние",
        "Расчётный\nсрок",
    ],
    ["1", "Территория", None, None, None],
    ["", "Площадь проектируемой территории", "га", "36,1", "36,16"],
    ["2", "Жилищный фонд", None, None, None],
    ["2.1", "Общая площадь жилых помещений", "тыс. кв. м", "", "120,5"],
]

# Минимальные данные Табл.13 (очередность)
_TABLE13_ROWS: list[list[str | None]] = [
    # Заголовок
    [
        "Номер\nочереди\nстроит-ва",
        "Состав этапа строительства",
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        "Зона планируемого\nразмещения",
        None,
        "Площадь зон, га",
        None,
        "Источник\nфинансирования",
        None,
        "Программа",
    ],
    # Строка-нумерация «1 2 4 5 6 7»
    [
        "1",
        "2",
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        "4",
        None,
        "5",
        None,
        "6",
        None,
        "7",
    ],
    # Реальные строки
    [
        "I этап\n2018-2021",
        "",
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        "Объекты капитального строительства",
        None,
        None,
        None,
        None,
        "",
        None,
        None,
        None,
    ],
    [
        None,
        "",
        "",
        "Многофункциональная ледовая арена",
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        "",
        "Зона объектов спорта",
        None,
        "4,97",
        None,
        "Внебюджетные",
        None,
        "",
    ],
]


# ── Тесты _parse_table11 ──────────────────────────────────────────────────────


class TestParseTable11:
    def test_returns_list(self) -> None:
        result = _parse_table11(_TABLE11_ROWS)
        assert isinstance(result, list)

    def test_correct_row_count(self) -> None:
        """Должны получить 2 зоны + 1 Итого-строку."""
        result = _parse_table11(_TABLE11_ROWS)
        assert len(result) == 3

    def test_zone_name_present(self) -> None:
        result = _parse_table11(_TABLE11_ROWS)
        zone_names = [r["zone_name"] for r in result]
        assert any("Многофункциональная" in n for n in zone_names)

    def test_numeric_parsed(self) -> None:
        result = _parse_table11(_TABLE11_ROWS)
        row1 = next(r for r in result if r["no"] == "1")
        assert row1["existing_ha_num"] == pytest.approx(15.41, abs=0.01)
        assert row1["project_ha_num"] == pytest.approx(11.34, abs=0.01)

    def test_itogo_row_flagged(self) -> None:
        result = _parse_table11(_TABLE11_ROWS)
        totals = [r for r in result if r.get("is_total")]
        assert len(totals) == 1

    def test_empty_input_returns_empty(self) -> None:
        assert _parse_table11([]) == []

    def test_missing_table_returns_empty(self) -> None:
        """Строки без полезного содержимого → пустой результат."""
        garbage = [["", None, None, None], [None, None, None, None]]
        result = _parse_table11(garbage)
        assert result == []

    def test_preamble_artifact_filtered_out(self) -> None:
        """Post-фильтр #1136: строки с № но без zone_name отбрасываются.

        Это «фантомные» строки преамбульного merged-cells шума (на ppt2018_22823
        давали 2 артефакта при 12 реальных зонах).
        """
        rows: list[list[str | None]] = [
            ["№", "Наименование зоны", "Площадь", None],
            [None, None, "Сущ., га (%)", "Проект, га\n(%)"],
            # Артефакт: № есть, но zone_name пустой — должен отброситься.
            ["1", "", "15,41 га", "11,34 га"],
            # Артефакт: № есть, zone_name пустой (None) — должен отброситься.
            ["2", None, "0,24 га", "0,25 га"],
            # Реальная зона: остаётся.
            ["3", "Зона жилой застройки", "10,0 га", "12,0 га"],
            # Итого: остаётся (zone_name пуст, но is_total).
            ["Итого:", None, "25,65 га", "23,59 га"],
        ]
        result = _parse_table11(rows)
        zone_names = [r["zone_name"] for r in result]
        # Реальная зона должна быть в результате
        assert "Зона жилой застройки" in zone_names
        # Артефакты (zone_name пуст, не is_total) отброшены
        non_total_no_zone = [
            r for r in result if not r["zone_name"] and not r.get("is_total")
        ]
        assert non_total_no_zone == []
        # Итого-строка осталась
        assert any(r.get("is_total") for r in result)


# ── Тесты _parse_table12 ──────────────────────────────────────────────────────


class TestParseTable12:
    def test_returns_list(self) -> None:
        result = _parse_table12(_TABLE12_ROWS)
        assert isinstance(result, list)

    def test_header_skipped(self) -> None:
        result = _parse_table12(_TABLE12_ROWS)
        # Первая строка с «№»/«Наименование показателей» должна быть пропущена
        no_values = [r["no"] for r in result]
        assert "№" not in no_values

    def test_indicator_present(self) -> None:
        result = _parse_table12(_TABLE12_ROWS)
        indicators = [r["indicator"] for r in result]
        assert any("Территория" in i for i in indicators)

    def test_numeric_values(self) -> None:
        result = _parse_table12(_TABLE12_ROWS)
        area_row = next((r for r in result if "Площадь" in r["indicator"]), None)
        assert area_row is not None
        assert area_row["target"] == "36,16"

    def test_empty_input_returns_empty(self) -> None:
        assert _parse_table12([]) == []


# ── Тесты _parse_table13 ──────────────────────────────────────────────────────


class TestParseTable13:
    def test_returns_list(self) -> None:
        result = _parse_table13(_TABLE13_ROWS)
        assert isinstance(result, list)

    def test_phase_extracted(self) -> None:
        result = _parse_table13(_TABLE13_ROWS)
        phases = [r["phase"] for r in result if r["phase"]]
        assert any("этап" in p.lower() or "2018" in p for p in phases)

    def test_zone_extracted(self) -> None:
        result = _parse_table13(_TABLE13_ROWS)
        zones = [r["zone"] for r in result if r["zone"]]
        assert any("спорт" in z.lower() or "Зона" in z for z in zones)

    def test_area_ha_num_parsed(self) -> None:
        result = _parse_table13(_TABLE13_ROWS)
        rows_with_area = [r for r in result if r.get("area_ha_num") is not None]
        assert len(rows_with_area) >= 1
        areas = [r["area_ha_num"] for r in rows_with_area]
        assert any(abs(a - 4.97) < 0.01 for a in areas)

    def test_empty_input_returns_empty(self) -> None:
        assert _parse_table13([]) == []

    def test_dedup_removes_continuation_noise(self) -> None:
        """Post-фильтр #1136: дубликаты merged-cells continuation схлопываются.

        На ppt2018_22823 364 «строк» сворачивались в ~30-40 реальных.
        Проверяем dedup по (phase, composition, zone, area_ha_norm).
        """
        # Реальная строка и три её continuation-копии (merged-cells даёт повторы).
        real_row: list[str | None] = [
            "I этап\n2018-2021",
            "",
            "",
            "Многофункциональная ледовая арена",
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            "",
            "Зона объектов спорта",
            None,
            "4,97",
            None,
            "Внебюджетные",
            None,
            "",
        ]
        rows: list[list[str | None]] = [
            _TABLE13_ROWS[0],
            _TABLE13_ROWS[1],
            real_row,
            real_row,  # continuation-дубль 1
            real_row,  # continuation-дубль 2
            real_row,  # continuation-дубль 3
        ]
        result = _parse_table13(rows)
        # Все 4 копии должны схлопнуться в 1.
        matching = [
            r
            for r in result
            if r["zone"] == "Зона объектов спорта" and r.get("area_ha_num") == pytest.approx(4.97)
        ]
        assert len(matching) == 1

    def test_short_noise_rows_filtered(self) -> None:
        """Post-фильтр #1136: composition < 4 символов без zone — шум merged-cells."""
        rows: list[list[str | None]] = [
            _TABLE13_ROWS[0],
            _TABLE13_ROWS[1],
            # Шум: composition «-», без zone — должен отброситься
            [None, "-"] + [None] * 18,
            # Шум: composition «1», без zone — должен отброситься
            [None, "1"] + [None] * 18,
            # Реальная строка
            [
                "I этап",
                "",
                "Жилой комплекс",
                None,
                None,
                None,
                None,
                None,
                None,
                None,
                None,
                None,
                None,
                "Зона Ж-1",
                None,
                "5,0",
                None,
                "Бюджет",
                None,
                "",
            ],
        ]
        result = _parse_table13(rows)
        compositions = [r["composition"] for r in result]
        assert "-" not in compositions
        # Одиночная «1» должна отлететь как номер-осколок
        # (real_row даёт composition='Жилой комплекс').
        assert any("Жилой" in c for c in compositions)


# ── Тест parse_ppt_tep с mock pdfplumber ─────────────────────────────────────


def _make_mock_page(text: str, tables: list[list[list[str | None]]]) -> MagicMock:
    """Создаёт mock pdfplumber Page с заданным текстом и таблицами."""
    page = MagicMock()
    page.extract_text.return_value = text
    page.extract_tables.return_value = tables
    return page


class TestParsePptTepMocked:
    """Тесты parse_ppt_tep с мокированным pdfplumber.open."""

    def _build_mock_pdf(self) -> list[MagicMock]:
        """Собирает 3-страничный mock-PDF с Табл.11/12/13."""
        # Страница 1: Таблица 11 (баланс)
        page11 = _make_mock_page(
            "Таблица 11\nБаланс территории",
            [_TABLE11_ROWS],
        )
        # Страница 2: Таблица 12 (ТЭП)
        page12 = _make_mock_page(
            "Таблица 12\nОсновные технико-экономические показатели",
            [_TABLE12_ROWS],
        )
        # Страница 3: Таблица 13 (очередность)
        page13 = _make_mock_page(
            "Таблица 13\nОчередность строительства",
            [_TABLE13_ROWS],
        )
        return [page11, page12, page13]

    def test_returns_ppt_tep_instance(self) -> None:
        pages = self._build_mock_pdf()
        ctx = MagicMock()
        ctx.__enter__ = MagicMock(return_value=ctx)
        ctx.__exit__ = MagicMock(return_value=False)
        ctx.pages = pages

        with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
            result = parse_ppt_tep(b"fake-pdf-bytes")

        assert isinstance(result, PptTep)

    def test_zone_balance_not_empty(self) -> None:
        pages = self._build_mock_pdf()
        ctx = MagicMock()
        ctx.__enter__ = MagicMock(return_value=ctx)
        ctx.__exit__ = MagicMock(return_value=False)
        ctx.pages = pages

        with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
            result = parse_ppt_tep(b"fake-pdf-bytes")

        assert len(result.zone_balance) > 0, "zone_balance не должен быть пустым"

    def test_tep_not_empty(self) -> None:
        pages = self._build_mock_pdf()
        ctx = MagicMock()
        ctx.__enter__ = MagicMock(return_value=ctx)
        ctx.__exit__ = MagicMock(return_value=False)
        ctx.pages = pages

        with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
            result = parse_ppt_tep(b"fake-pdf-bytes")

        assert len(result.tep) > 0, "tep не должен быть пустым"

    def test_phasing_not_empty(self) -> None:
        pages = self._build_mock_pdf()
        ctx = MagicMock()
        ctx.__enter__ = MagicMock(return_value=ctx)
        ctx.__exit__ = MagicMock(return_value=False)
        ctx.pages = pages

        with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
            result = parse_ppt_tep(b"fake-pdf-bytes")

        assert len(result.phasing) > 0, "phasing не должен быть пустым"

    def test_missing_table13_returns_empty_phasing(self) -> None:
        """Если Табл.13 нет в PDF — phasing = []."""
        pages_no13 = self._build_mock_pdf()[:2]  # только стр. 1-2
        ctx = MagicMock()
        ctx.__enter__ = MagicMock(return_value=ctx)
        ctx.__exit__ = MagicMock(return_value=False)
        ctx.pages = pages_no13

        with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
            result = parse_ppt_tep(b"fake-pdf-bytes")

        assert result.phasing == [], "phasing должен быть [] если Табл.13 не найдена"

    def test_missing_all_tables_returns_empty_result(self) -> None:
        """PDF без таблиц → все поля []."""
        page_empty = _make_mock_page("Пустая страница без таблиц", [])
        ctx = MagicMock()
        ctx.__enter__ = MagicMock(return_value=ctx)
        ctx.__exit__ = MagicMock(return_value=False)
        ctx.pages = [page_empty]

        with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
            result = parse_ppt_tep(b"fake-pdf-bytes")

        assert result.zone_balance == []
        assert result.tep == []
        assert result.phasing == []

    def test_raw_tables_populated(self) -> None:
        """raw_tables заполняется данными о найденных таблицах."""
        pages = self._build_mock_pdf()
        ctx = MagicMock()
        ctx.__enter__ = MagicMock(return_value=ctx)
        ctx.__exit__ = MagicMock(return_value=False)
        ctx.pages = pages

        with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx):
            result = parse_ppt_tep(b"fake-pdf-bytes")

        assert len(result.raw_tables) > 0
        # Проверяем что raw_tables содержит page, table_index, rows
        first = result.raw_tables[0]
        assert "page" in first
        assert "table_index" in first
        assert "rows" in first


# ── Тест на реальном PDF-образце (skip в CI) ─────────────────────────────────

_REAL_PDF_PATH = Path(
    "C:/Users/user/source/repos/gendesign/.playwright-mcp/ekb-docs/ppt2018_22823_poyasnit_PPT.pdf"
)


@pytest.mark.skipif(
    not _REAL_PDF_PATH.exists(),
    reason="Реальный PDF-образец отсутствует (только для локального запуска)",
)
class TestParsePptTepRealPdf:
    """Интеграционные тесты на реальном образце ppt2018_22823."""

    @pytest.fixture(scope="class")
    def result(self) -> PptTep:
        pdf_bytes = _REAL_PDF_PATH.read_bytes()
        return parse_ppt_tep(pdf_bytes)

    def test_zone_balance_has_rows(self, result: PptTep) -> None:
        """Табл.11 должна иметь ≥5 зон."""
        n = len(result.zone_balance)
        assert n >= 5, f"Ожидалось ≥5 зон, получено {n}"

    def test_zone_balance_has_itogo(self, result: PptTep) -> None:
        """Итоговая строка присутствует в Табл.11."""
        has_total = any(
            "итого" in r.get("zone_name", "").lower() or r.get("is_total")
            for r in result.zone_balance
        )
        assert has_total, "Итого-строка не найдена в zone_balance"

    def test_tep_has_rows(self, result: PptTep) -> None:
        """Табл.12 должна иметь ≥10 показателей."""
        assert len(result.tep) >= 10, f"Ожидалось ≥10 показателей, получено {len(result.tep)}"

    def test_phasing_has_rows(self, result: PptTep) -> None:
        """Табл.13 должна иметь ≥3 строки."""
        n = len(result.phasing)
        assert n >= 3, f"Ожидалось ≥3 строки очередности, получено {n}"

    def test_phasing_has_area(self, result: PptTep) -> None:
        """Хотя бы одна строка очередности содержит площадь > 0."""
        areas = [r["area_ha_num"] for r in result.phasing if r.get("area_ha_num")]
        assert areas, "Площади зон в Табл.13 не распарсились"
        assert max(areas) > 0

    def test_raw_tables_not_empty(self, result: PptTep) -> None:
        """raw_tables содержит хотя бы 3 записи (по одной на таблицу)."""
        assert len(result.raw_tables) >= 3
