"""Общий harness для parity-тестов legacy → scraper_kit (issue #2304).

Каждая миграция importer'а legacy `app/services/scrapers/*` на его
`scraper_kit` эквивалент (issues #2305-#2310) должна доказать, что kit-путь
даёт ТОТ ЖЕ результат, что и legacy-путь на одном и том же входе. Это —
переиспользуемый инструмент для такого доказательства, НЕ сама миграция.

Usage (см. tests/support/README.md для подробностей)::

    from tests.support.parity import assert_parity

    def test_parse_detail_html_parity() -> None:
        assert_parity(
            legacy_fn=legacy_parse_detail_html,
            kit_fn=kit_parse_detail_html,
            fixtures=[(html, source_url)],
            ignore_fields={"latency_ms", "fetched_at"},
            tolerance=1e-6,
        )

Design note: legacy- и kit-версии одного и того же dataclass (напр.
``DetailEnrichment``) — это РАЗНЫЕ классы (разные модули), поэтому обычный
``==`` между их инстансами всегда False (dataclass ``__eq__`` сравнивает
``other.__class__ is self.__class__`` первым делом). Поэтому сравнение
нормализует оба вывода в dict/list/scalar (``_normalize``) и сравнивает
СТРУКТУРНО, по полям — а не через identity классов.
"""

from __future__ import annotations

import dataclasses
import math
from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import Any


class ParityMismatchError(AssertionError):
    """legacy_fn(...) и kit_fn(...) дали разный результат на одной фикстуре.

    Подкласс AssertionError — совместим с `pytest.raises(AssertionError)`
    и обычным assert-репортингом pytest.
    """


def _normalize(value: Any) -> Any:
    """Приводит dataclass / pydantic-модель к сравнимому dict/list/scalar.

    Рекурсивно — вложенные dataclass/pydantic-поля тоже нормализуются.
    """
    if dataclasses.is_dataclass(value) and not isinstance(value, type):
        return {f.name: _normalize(getattr(value, f.name)) for f in dataclasses.fields(value)}
    if hasattr(value, "model_dump") and callable(value.model_dump):
        return _normalize(value.model_dump())
    if isinstance(value, Mapping):
        return {k: _normalize(v) for k, v in value.items()}
    if isinstance(value, list | tuple):
        return [_normalize(v) for v in value]
    return value


def _is_number(value: Any) -> bool:
    # bool — подкласс int, но семантически не "число с допуском".
    return isinstance(value, int | float) and not isinstance(value, bool)


def _within_tolerance(legacy_val: Any, kit_val: Any, tolerance: float) -> bool:
    if not tolerance or not _is_number(legacy_val) or not _is_number(kit_val):
        return False
    return math.isclose(float(legacy_val), float(kit_val), rel_tol=tolerance, abs_tol=tolerance)


def _diff(
    path: str,
    legacy_val: Any,
    kit_val: Any,
    ignore_fields: frozenset[str],
    tolerance: float,
    diffs: list[str],
) -> None:
    legacy_val = _normalize(legacy_val)
    kit_val = _normalize(kit_val)

    legacy_is_map = isinstance(legacy_val, Mapping)
    kit_is_map = isinstance(kit_val, Mapping)
    if legacy_is_map or kit_is_map:
        if not (legacy_is_map and kit_is_map):
            diffs.append(f"{path}: type mismatch — legacy={legacy_val!r}, kit={kit_val!r}")
            return
        for key in sorted({*legacy_val.keys(), *kit_val.keys()}, key=str):
            if key in ignore_fields:
                continue
            child_path = f"{path}.{key}"
            if key not in legacy_val:
                diffs.append(f"{child_path}: missing in legacy, kit={kit_val[key]!r}")
            elif key not in kit_val:
                diffs.append(f"{child_path}: legacy={legacy_val[key]!r}, missing in kit")
            else:
                _diff(child_path, legacy_val[key], kit_val[key], ignore_fields, tolerance, diffs)
        return

    legacy_is_seq = isinstance(legacy_val, list | tuple)
    kit_is_seq = isinstance(kit_val, list | tuple)
    if legacy_is_seq or kit_is_seq:
        if not (legacy_is_seq and kit_is_seq):
            diffs.append(f"{path}: type mismatch — legacy={legacy_val!r}, kit={kit_val!r}")
            return
        if len(legacy_val) != len(kit_val):
            diffs.append(f"{path}: length differs — legacy={len(legacy_val)}, kit={len(kit_val)}")
            return
        for i, (lv, kv) in enumerate(zip(legacy_val, kit_val, strict=True)):
            _diff(f"{path}[{i}]", lv, kv, ignore_fields, tolerance, diffs)
        return

    if isinstance(legacy_val, bool) != isinstance(kit_val, bool):
        # bool — подкласс int в Python, поэтому `True == 1` и `False == 0` дают True
        # при обычном `==` (проверять надо isinstance(x, bool), НЕ isinstance(x, int)).
        # Миграция, случайно превратившая `bool | None` поле в сырой 0/1 (или наоборот),
        # должна считаться расхождением, даже если числовое значение совпадает.
        diffs.append(
            f"{path}: type mismatch (bool vs non-bool) — legacy={legacy_val!r}, kit={kit_val!r}"
        )
        return
    if legacy_val == kit_val:
        return
    if _within_tolerance(legacy_val, kit_val, tolerance):
        return
    diffs.append(f"{path}: legacy={legacy_val!r} != kit={kit_val!r}")


def compare_outputs(
    legacy_output: Any,
    kit_output: Any,
    *,
    ignore_fields: Iterable[str] = (),
    tolerance: float = 0.0,
) -> list[str]:
    """Возвращает список текстовых различий между двумя выводами (пусто = совпадают).

    ignore_fields — имена полей (ключи dict / поля dataclass на ЛЮБОМ уровне
    вложенности), которые пропускаются при сравнении — для недетерминированных
    полей вроде ``latency_ms`` / ``fetched_at`` / ``request_id``.
    tolerance — абсолютно-и-относительный допуск (``math.isclose``) для
    числовых полей (int/float, НЕ bool). Не применяется к datetime/date —
    такие поля, если недетерминированы, кладите в ignore_fields.
    """
    diffs: list[str] = []
    _diff("$", legacy_output, kit_output, frozenset(ignore_fields), tolerance, diffs)
    return diffs


def assert_parity(
    legacy_fn: Callable[..., Any],
    kit_fn: Callable[..., Any],
    fixtures: Sequence[Any],
    *,
    ignore_fields: Iterable[str] = (),
    tolerance: float = 0.0,
) -> None:
    """Прогоняет legacy_fn и kit_fn на каждой фикстуре, сверяет вывод.

    Args:
        legacy_fn: функция из `app/services/scrapers/*` (legacy-путь).
        kit_fn: соответствующая функция из `scraper_kit` (kit-путь).
        fixtures: список входов. Каждый элемент фикстуры:
            - tuple/list → распаковывается как позиционные аргументы (``*args``);
            - любое другое значение (dict, str, ...) → передаётся как ЕДИНСТВЕННЫЙ
              позиционный аргумент.
        ignore_fields: имена полей, игнорируемых при сравнении (см. compare_outputs).
        tolerance: числовой допуск для float-полей (см. compare_outputs).

    Raises:
        ParityMismatchError: если legacy_fn(...) != kit_fn(...) хотя бы на одной
            фикстуре. Сообщение перечисляет ВСЕ различающиеся поля (path + old/new).
    """
    for i, fixture in enumerate(fixtures):
        args = fixture if isinstance(fixture, tuple | list) else (fixture,)
        legacy_result = legacy_fn(*args)
        kit_result = kit_fn(*args)

        diffs = compare_outputs(
            legacy_result, kit_result, ignore_fields=ignore_fields, tolerance=tolerance
        )
        if diffs:
            diff_text = "\n".join(f"  - {d}" for d in diffs)
            raise ParityMismatchError(
                f"Parity mismatch on fixture #{i} (input={fixture!r}):\n{diff_text}"
            )
