"""ET-013 — unit-тесты zoom-aware paint для hillshade и TRI.

ET-013 — фронтенд-калибровка растровых terrain-слоёв (см. ADR-017).
В CI исполняется только ``pytest tests/``, JS-тест-раннера в проекте нет,
поэтому проверки выполнены как статический парсинг ``src/web/app.js``
и ``src/web/index.html`` (см. TRZ REQ-F-13 Вариант B).

Покрытие тест-плана (`04-test-plan.yaml`):
- UT-PAINT-HS-OPACITY, UT-PAINT-HS-CONTRAST, UT-PAINT-HS-RESAMPLING
- UT-PAINT-TRI-OPACITY-Z8, UT-PAINT-TRI-OPACITY-PEAK, UT-PAINT-TRI-RESAMPLING
- UT-PAINT-COMPAT-01, UT-PAINT-COMPAT-02
- UT-REG-MINZOOM-9, UT-REG-HINT-TEXT, UT-REG-CALLERS
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
APP_JS = REPO_ROOT / "src" / "web" / "app.js"
INDEX_HTML = REPO_ROOT / "src" / "web" / "index.html"


def _app_js() -> str:
    assert APP_JS.is_file(), f"не найден {APP_JS}"
    return APP_JS.read_text(encoding="utf-8")


def _index_html() -> str:
    assert INDEX_HTML.is_file(), f"не найден {INDEX_HTML}"
    return INDEX_HTML.read_text(encoding="utf-8")


def _extract_block(name: str, src: str) -> str:
    """Достать тело объявления `const NAME = { ... };` (один уровень фигурных скобок)."""
    start_match = re.search(rf"const\s+{re.escape(name)}\s*=\s*\{{", src)
    assert start_match, f"не найдено объявление {name}"
    i = start_match.end() - 1  # позиция открывающей `{`
    depth = 0
    end = -1
    while i < len(src):
        ch = src[i]
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                end = i
                break
        i += 1
    assert end > 0, f"не найден конец объявления {name}"
    return src[start_match.end() - 1:end + 1]


def _parse_zoom_stops(interpolate_src: str) -> dict[int, float]:
    """Достать пары (zoom, value) из 'interpolate' блока. Толерантно к пробелам/переносам."""
    # ищем секцию ['zoom'] и далее парами «целое, число»
    zoom_pos = interpolate_src.find("['zoom']")
    assert zoom_pos > 0, "ожидался ['zoom'] в interpolate-выражении"
    tail = interpolate_src[zoom_pos + len("['zoom']"):]
    # тело продолжается до закрывающей ']' уровня нашего массива; ищем все числа
    # сначала отрезаем хвост по конечной `]`
    bracket_close = tail.rfind("]")
    assert bracket_close > 0, "не найден конец interpolate-массива"
    body = tail[:bracket_close]
    nums = re.findall(r"-?\d+(?:\.\d+)?", body)
    assert len(nums) % 2 == 0 and nums, (
        f"ожидаются чётные пары (zoom, value), получено {nums}"
    )
    stops: dict[int, float] = {}
    for i in range(0, len(nums), 2):
        z = int(float(nums[i]))
        v = float(nums[i + 1])
        stops[z] = v
    return stops


# ──────────────────────────────────────────────────────────────────────────────
# HILLSHADE_PAINT (REQ-F-05, F-06, F-07; AC-04)
# ──────────────────────────────────────────────────────────────────────────────

def test_hillshade_paint_defined():
    """REQ-F-05: HILLSHADE_PAINT объявлен в app.js."""
    js = _app_js()
    assert "const HILLSHADE_PAINT" in js, "HILLSHADE_PAINT не объявлен"


def test_hillshade_opacity_is_interpolate_by_zoom():
    """UT-PAINT-HS-OPACITY: raster-opacity — interpolate linear по zoom."""
    block = _extract_block("HILLSHADE_PAINT", _app_js())
    # достаём массив 'raster-opacity'
    m = re.search(r"'raster-opacity'\s*:\s*\[(.*?)\]\s*,\s*'raster-contrast'", block, re.DOTALL)
    assert m, "не найдена секция 'raster-opacity' в HILLSHADE_PAINT"
    op_src = "[" + m.group(1) + "]"
    assert "'interpolate'" in op_src, "raster-opacity должен быть 'interpolate'"
    assert "'linear'" in op_src, "ожидается linear-interpolate"
    assert "'zoom'" in op_src, "ожидается интерполяция по zoom"


def test_hillshade_opacity_stops():
    """UT-PAINT-HS-OPACITY: stops по zoom монотонно убывают, ключевые значения совпадают."""
    block = _extract_block("HILLSHADE_PAINT", _app_js())
    m = re.search(r"'raster-opacity'\s*:\s*(\[.*?\])\s*,\s*'raster-contrast'", block, re.DOTALL)
    assert m
    stops = _parse_zoom_stops(m.group(1))
    # требования ADR-017 / TRZ §3 REQ-F-05
    assert 9 in stops and stops[9] == pytest.approx(0.65, abs=0.001)
    assert 11 in stops and stops[11] == pytest.approx(0.55, abs=0.001)
    assert 14 in stops and stops[14] == pytest.approx(0.40, abs=0.001)
    # монотонность 9 → 14
    zooms = sorted(stops.keys())
    values = [stops[z] for z in zooms]
    assert values == sorted(values, reverse=True), (
        f"raster-opacity hillshade не монотонно убывает: {stops}"
    )


def test_hillshade_contrast_peak_z9():
    """UT-PAINT-HS-CONTRAST: contrast на z9 ≥ 0.30, на z14 ≤ 0.10, монотонно убывает."""
    block = _extract_block("HILLSHADE_PAINT", _app_js())
    m = re.search(
        r"'raster-contrast'\s*:\s*(\[.*?\])\s*,\s*'raster-resampling'",
        block,
        re.DOTALL,
    )
    assert m, "не найдена секция 'raster-contrast' в HILLSHADE_PAINT"
    contrast_src = m.group(1)
    assert "'interpolate'" in contrast_src
    stops = _parse_zoom_stops(contrast_src)
    assert stops[9] >= 0.30, f"z=9 contrast должен быть ≥0.30, факт {stops[9]}"
    assert stops[14] <= 0.10, f"z=14 contrast должен быть ≤0.10, факт {stops[14]}"
    zooms = sorted(stops.keys())
    values = [stops[z] for z in zooms]
    assert values == sorted(values, reverse=True), (
        f"raster-contrast hillshade не монотонно убывает: {stops}"
    )


def test_hillshade_resampling_nearest():
    """UT-PAINT-HS-RESAMPLING: raster-resampling = 'nearest'."""
    block = _extract_block("HILLSHADE_PAINT", _app_js())
    assert "'raster-resampling': 'nearest'" in block, (
        "HILLSHADE_PAINT должен использовать nearest-resampling"
    )


# ──────────────────────────────────────────────────────────────────────────────
# TRI_PAINT (REQ-F-08, F-09; AC-05, AC-06)
# ──────────────────────────────────────────────────────────────────────────────

def test_tri_paint_defined():
    """REQ-F-08: TRI_PAINT объявлен в app.js."""
    js = _app_js()
    assert "const TRI_PAINT" in js, "TRI_PAINT не объявлен"


def test_tri_opacity_z8_regression():
    """UT-PAINT-TRI-OPACITY-Z8 (AC-06): на z=8 opacity = 0.70 ровно (регрессия)."""
    block = _extract_block("TRI_PAINT", _app_js())
    m = re.search(r"'raster-opacity'\s*:\s*(\[.*?\])\s*,\s*'raster-resampling'", block, re.DOTALL)
    assert m
    stops = _parse_zoom_stops(m.group(1))
    assert 8 in stops and stops[8] == pytest.approx(0.70, abs=0.001), (
        f"регрессия z8: TRI opacity должен быть 0.70, факт {stops.get(8)}"
    )


def test_tri_opacity_peak_z9_z11():
    """UT-PAINT-TRI-OPACITY-PEAK: на z9-z11 opacity ≥ 0.80."""
    block = _extract_block("TRI_PAINT", _app_js())
    m = re.search(r"'raster-opacity'\s*:\s*(\[.*?\])\s*,\s*'raster-resampling'", block, re.DOTALL)
    assert m
    stops = _parse_zoom_stops(m.group(1))
    assert stops[10] >= 0.80, f"z=10 TRI opacity должен быть ≥0.80, факт {stops[10]}"
    assert stops[11] >= 0.80, f"z=11 TRI opacity должен быть ≥0.80, факт {stops[11]}"


def test_tri_resampling_nearest():
    """UT-PAINT-TRI-RESAMPLING: raster-resampling = 'nearest'."""
    block = _extract_block("TRI_PAINT", _app_js())
    assert "'raster-resampling': 'nearest'" in block, (
        "TRI_PAINT должен использовать nearest-resampling"
    )


# ──────────────────────────────────────────────────────────────────────────────
# applyTerrainLayer: обратная совместимость (REQ-F-04; AC-22)
# ──────────────────────────────────────────────────────────────────────────────

def test_apply_terrain_layer_signature_uses_opacity_or_paint():
    """UT-PAINT-COMPAT-01: сигнатура использует opacityOrPaint."""
    js = _app_js()
    assert (
        "function applyTerrainLayer(id, tileUrl, enabled, opacityOrPaint, minzoom, maxzoom)"
        in js
    ), "сигнатура applyTerrainLayer должна принимать opacityOrPaint"


def test_apply_terrain_layer_normalizes_number_to_legacy_paint():
    """UT-PAINT-COMPAT-01: ветвление по typeof opacityOrPaint === 'number'."""
    js = _app_js()
    assert "typeof opacityOrPaint === 'number'" in js, (
        "applyTerrainLayer должен ветвиться по типу (number → legacy paint)"
    )
    # «старый» путь должен собирать legacy-paint с linear-resampling
    assert "'raster-opacity': opacityOrPaint" in js, (
        "при числовом opacityOrPaint paint должен содержать 'raster-opacity': opacityOrPaint"
    )
    assert "'raster-resampling': 'linear'" in js, (
        "legacy-ветка должна использовать linear-resampling"
    )


def test_apply_terrain_layer_uses_paint_variable():
    """UT-PAINT-COMPAT-02: объект paint пробрасывается в map.addLayer как есть."""
    js = _app_js()
    # после нормализации код должен передавать `paint: paint` в addLayer
    assert re.search(r"paint:\s*paint\s*,", js), (
        "applyTerrainLayer должен использовать переменную `paint` в map.addLayer"
    )


# ──────────────────────────────────────────────────────────────────────────────
# Регрессии: пороги, hint, callers (REQ-F-01, F-10, F-14)
# ──────────────────────────────────────────────────────────────────────────────

def test_minzoom_threshold_lowered_to_9():
    """UT-REG-MINZOOM-9 (AC-01): updateHillshadeAvailability использует порог 9."""
    js = _app_js()
    # внутри updateHillshadeAvailability должно быть `zoom < 9`
    m = re.search(
        r"function updateHillshadeAvailability\(\)\s*\{(.*?)^\}",
        js,
        re.DOTALL | re.MULTILINE,
    )
    assert m, "не найдена функция updateHillshadeAvailability"
    body = m.group(1)
    assert "zoom < 9" in body, "порог должен быть `zoom < 9`"
    assert "zoom < 10" not in body, "старый порог `zoom < 10` должен быть удалён"


def test_hint_text_updated_to_z9():
    """UT-REG-HINT-TEXT (AC-01): hint содержит «Зум 9+»."""
    html = _index_html()
    # ищем содержимое #terrain-hillshade-hint
    m = re.search(
        r'id="terrain-hillshade-hint"[^>]*>\s*([^<]+)\s*</span>',
        html,
    )
    assert m, "не найден #terrain-hillshade-hint в index.html"
    text = m.group(1).strip()
    assert text == "Зум 9+", f"hint должен быть «Зум 9+», факт «{text}»"


def test_apply_terrain_layer_caller_count():
    """UT-REG-CALLERS: applyTerrainLayer вызывается минимум 2 раза в onTerrainCheckbox."""
    js = _app_js()
    # ищем вызовы (исключая саму декларацию функции)
    pattern = re.compile(r"applyTerrainLayer\s*\(")
    matches = pattern.findall(js)
    # одно совпадение — объявление функции, остальные — вызовы
    assert len(matches) >= 3, (
        f"ожидается ≥3 вхождений applyTerrainLayer (1 декл. + ≥2 вызова), факт {len(matches)}"
    )


def test_hillshade_call_uses_paint_constant_and_minzoom_9():
    """REQ-F-02 + REQ-F-05: вызов hillshade использует HILLSHADE_PAINT и minzoom=9."""
    js = _app_js()
    # ищем строку вызова, привязанную к hillshade
    m = re.search(
        r"applyTerrainLayer\(\s*'terrain-hillshade'\s*,\s*[^,]+,\s*hillshadeChecked\s*,\s*([A-Za-z_]+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)",
        js,
    )
    assert m, "вызов applyTerrainLayer для terrain-hillshade не найден"
    paint_arg, minz, maxz = m.group(1), int(m.group(2)), int(m.group(3))
    assert paint_arg == "HILLSHADE_PAINT", (
        f"hillshade должен использовать HILLSHADE_PAINT, факт {paint_arg}"
    )
    assert minz == 9, f"hillshade minzoom должен быть 9, факт {minz}"
    assert maxz == 15, f"hillshade maxzoom должен быть 15, факт {maxz}"


def test_tri_call_uses_paint_constant_and_minzoom_5():
    """REQ-F-03 + REQ-F-08: вызов TRI использует TRI_PAINT и minzoom=5 (без изменений)."""
    js = _app_js()
    m = re.search(
        r"applyTerrainLayer\(\s*'terrain-tri'\s*,\s*[^,]+,\s*triChecked\s*,\s*([A-Za-z_]+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)",
        js,
    )
    assert m, "вызов applyTerrainLayer для terrain-tri не найден"
    paint_arg, minz, maxz = m.group(1), int(m.group(2)), int(m.group(3))
    assert paint_arg == "TRI_PAINT", (
        f"tri должен использовать TRI_PAINT, факт {paint_arg}"
    )
    assert minz == 5, f"tri minzoom должен быть 5, факт {minz}"
    assert maxz == 15, f"tri maxzoom должен быть 15, факт {maxz}"
