"""Контракты DAG world_init и world_next_day без запуска Airflow.

Что здесь контракт. Проверяем только структурные и поведенческие инварианты
стенда: идентификаторы DAG и задач, порядок задач, пути монтирования, синхронность
версий, живые перекрёстные ссылки между документами и реальные идентификаторы
(имена дашбордов, профилей, make-целей). Критерий: инвариант должен переживать
честную переписку текста — краснеть, только когда ломается само поведение или
структура, а не формулировка.

Чего сюда не добавлять: дословные цитаты прозы из документов и комментариев
(«в README есть слово X», «в комментарии написано Y»). Прозу сторожит ревью, а не
pytest — такой тест зелёный до первой честной переписки и краснеет без дефекта.
"""

import ast
from pathlib import Path

from clickstream_generator.launch import PROFILES


DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "world_init_dag.py"
NEXT_DAY_DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "world_next_day_dag.py"
REPO_ROOT = Path(__file__).parents[2]


def _tree():
    return ast.parse(DAG_PATH.read_text(encoding="utf-8"))


def _declared_param_types() -> dict[str, set[str]]:
    param_types = {}
    for node in ast.walk(_tree()):
        if not isinstance(node, ast.Dict):
            continue
        for key, value in zip(node.keys, node.values, strict=True):
            if not (
                isinstance(key, ast.Constant)
                and isinstance(key.value, str)
                and isinstance(value, ast.Call)
                and isinstance(value.func, ast.Name)
                and value.func.id == "Param"
            ):
                continue
            type_keyword = next(
                keyword.value for keyword in value.keywords if keyword.arg == "type"
            )
            if isinstance(type_keyword, ast.List):
                param_types[key.value] = {
                    item.value for item in type_keyword.elts if isinstance(item, ast.Constant)
                }
            else:
                param_types[key.value] = {type_keyword.value}
    return param_types


def _declared_param_defaults() -> dict[str, object]:
    param_defaults = {}
    for node in ast.walk(_tree()):
        if not isinstance(node, ast.Dict):
            continue
        for key, value in zip(node.keys, node.values, strict=True):
            if not (
                isinstance(key, ast.Constant)
                and isinstance(key.value, str)
                and isinstance(value, ast.Call)
                and isinstance(value.func, ast.Name)
                and value.func.id == "Param"
                and value.args
            ):
                continue
            param_defaults[key.value] = ast.literal_eval(value.args[0])
    return param_defaults


def test_dag_file_exists_and_uses_dynamic_profiles():
    """DAG берёт варианты профилей из PROFILES, а не из ручного списка."""
    text = DAG_PATH.read_text(encoding="utf-8")

    assert "dag_id=\"world_init\"" in text
    assert "sorted(PROFILES)" in text
    for profile in PROFILES:
        assert profile not in {"hardcoded-profile"}


def test_trigger_form_has_expected_param_enums():
    """Форма запуска ограничивает операции и профили."""
    text = DAG_PATH.read_text(encoding="utf-8")
    param_defaults = _declared_param_defaults()

    assert 'enum=["backfill", "import", "check"]' in text
    assert param_defaults["operation"] == "import"
    assert "enum=sorted(PROFILES)" in text
    assert param_defaults["profile"] == "daily-wave"
    assert '"duration": Param(' in text
    assert '"artifact_path": Param(' in text
    assert '"expected_t_end": Param(' not in text


def test_trigger_form_marks_only_optional_params_as_nullable():
    """Форма разрешает оставить необязательные поля пустыми."""
    param_types = _declared_param_types()

    for name in {
        "duration",
        "seed",
        "model_time_speed",
        "artifact_path",
    }:
        assert "null" in param_types[name]
    for name in {"operation", "profile"}:
        assert "null" not in param_types[name]


def test_dag_branches_and_waits_for_etl_completion():
    """Backfill/import запускают ETL и ждут завершения перед check."""
    text = DAG_PATH.read_text(encoding="utf-8")

    assert "BranchPythonOperator" in text
    assert "TriggerDagRunOperator" in text
    assert "trigger_dag_id=ETL_DAG_ID" in text
    assert "wait_for_completion=True" in text
    assert 'allowed_states=["success"]' in text
    assert 'failed_states=["failed"]' in text


def test_world_next_day_is_parameterless_paused_half_hour_dag():
    """DAG следующего дня запускается пустой формой каждые полчаса."""
    text = NEXT_DAY_DAG_PATH.read_text(encoding="utf-8")

    assert 'dag_id="world_next_day"' in text
    assert 'schedule="*/30 * * * *"' in text
    assert "catchup=False" in text
    assert "is_paused_upon_creation=True" in text
    assert "max_active_runs=1" in text
    assert "params=" not in text
    assert "precheck_next_day" in text
    assert "run_next_day_task" in text
    assert "TriggerDagRunOperator" in text
    assert "wait_for_completion=True" in text
    assert "check_manifest_task" in text
    assert "check_etl_not_paused >> precheck" in text


def test_world_dags_precheck_etl_dag_not_paused_before_waiting():
    """Пульт проверяет паузу etl_pipeline до долгого ожидания."""
    text = DAG_PATH.read_text(encoding="utf-8")
    shared_tasks = (
        REPO_ROOT / "airflow" / "dags" / "utils" / "startup_history_tasks.py"
    ).read_text(encoding="utf-8")

    assert "assert_target_dag_not_paused" in text
    assert 'ETL_DAG_ID = "etl_pipeline"' in text
    assert 'op_kwargs={"dag_id": ETL_DAG_ID}' in text
    assert "session.query(DagModel)" in shared_tasks
    assert "DagModel.dag_id == dag_id" in shared_tasks
    assert "target_dag_trigger_error" in shared_tasks
    assert "fail_when_dag_is_paused" in shared_tasks
    assert 'return "check_etl_not_paused_before_backfill"' in text
    assert 'return "check_etl_not_paused_before_import"' in text
    assert "check_etl_not_paused_before_backfill >> precheck_backfill_task" in text
    assert "check_etl_not_paused_before_import >> precheck_import_task" in text
    assert text.index("check_etl_not_paused_before_backfill >> precheck_backfill_task") < text.index(
        "precheck_backfill_task >> backfill_task"
    )
    assert text.index("check_etl_not_paused_before_import >> precheck_import_task") < text.index(
        "precheck_import_task >> import_task"
    )


def test_make_up_rebuilds_airflow_images_after_repo_update():
    """make up пересобирает Airflow, но не запускает Superset до DM."""
    text = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")

    up_block = text.split("\nup:\n", maxsplit=1)[1].split(
        "\n\n# Остановить",
        maxsplit=1,
    )[0]
    assert "$(COMPOSE) up -d --build" in up_block
    assert "airflow-webserver" in up_block
    assert "airflow-scheduler" in up_block
    assert "superset" not in up_block
    assert "superset-init" not in up_block


def test_readme_quick_start_creates_schema_via_ddl_init_before_world_init():
    """Быстрый старт: терминал — только make up, схему создаёт ddl_init до world_init."""
    text = (REPO_ROOT / "README.md").read_text(encoding="utf-8")

    # Ручного шага создания схемы в быстром старте быть не должно — её создаёт
    # DAG ddl_init. Проверяем этот структурный факт и порядок ddl_init→world_init,
    # а не наличие слов «uv»/«Docker» в тексте.
    assert "make ddl" not in text
    quick_start = text.split("## Быстрый старт", maxsplit=1)[1]
    assert quick_start.index("ddl_init") < quick_start.index("world_init")


def test_course_canonical_setup_unpauses_dags_before_world_init():
    """Канонический блок называет три DAG, снятие паузы и порядок etl→world_init."""
    # Прозаические формулировки не проверяем — их сторожит ревью, не pytest.
    # Держим только структурные факты: якорь-заголовок существует, в блоке
    # названы три DAG-а, упомянута пауза, а etl_pipeline идёт до world_init.
    text = (REPO_ROOT / "docs" / "course" / "README.md").read_text(encoding="utf-8")
    setup = text.split("## Подготовка и канонический сброс", maxsplit=1)[1].split(
        "## Уроки",
        maxsplit=1,
    )[0]

    assert "пауз" in setup
    for dag_id in ("ddl_init", "etl_pipeline", "world_init"):
        assert dag_id in setup
    assert setup.index("etl_pipeline") < setup.index("world_init")


def test_next_day_lab_uses_provisioned_superset_dashboard_name():
    """Лаба следующего дня ведёт на существующий дашборд Superset."""
    text = (
        REPO_ROOT / "docs" / "course" / "lessons" / "07_lab_next_day.md"
    ).read_text(encoding="utf-8")

    assert "E-commerce Analytics Dashboard" in text
    assert "Clickstream Analytics" not in text


def test_monitoring_lesson_links_to_canonical_reset():
    """Урок мониторинга ведёт обратно к каноническому сбросу курса."""
    # Проверяем живую перекрёстную ссылку: битый якорь — это дефект, а не
    # переписанная проза. Точные фразы упражнения не пиним.
    text = (
        REPO_ROOT / "docs" / "course" / "lessons" / "05_monitoring.md"
    ).read_text(encoding="utf-8")

    assert "../README.md#подготовка-и-канонический-сброс" in text


def test_startup_history_runbook_references_daily_wave_reset_and_import():
    """Runbook ссылается на реальные идентификаторы восстановления стенда."""
    # Не пиним формулировку предупреждения о дыре модельного времени — её
    # сторожит ревью. Держим ссылки на реальные идентификаторы: профиль,
    # make-цель сброса и якорь импорта.
    text = (REPO_ROOT / "docs" / "runbooks" / "startup-history.md").read_text(
        encoding="utf-8"
    )

    assert "daily-wave" in text
    assert "make generator-reset" in text
    assert "startup-history-import" in text


def test_no_docker_or_continue_operation_in_dag():
    """Пульт не управляет Docker и не содержит операцию continue."""
    text = DAG_PATH.read_text(encoding="utf-8")

    assert "docker" not in text.lower()
    assert '"continue"' not in text


def test_compose_mounts_generator_code_without_socket_and_gates_live_service():
    """Airflow видит код генератора, но не получает Docker socket."""
    text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")

    assert "PYTHONPATH: /opt/airflow/generator_src" in text
    assert "./generator/src:/opt/airflow/generator_src:ro" in text
    assert "/var/run/docker.sock" not in text
    assert "profiles:\n      - live-generator" in text


def test_down_and_clean_include_live_generator_profile():
    """Остановка стенда удаляет профильный live-генератор."""
    text = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")

    assert "down:\n\t$(COMPOSE) --profile live-generator down" in text
    assert "clean:\n\t$(COMPOSE) --profile live-generator down -v --remove-orphans" in text


def test_console_generator_paths_precheck_clean_stand_before_writes():
    """Backfill/import с хоста проверяют чистый стенд до записи."""
    run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
        encoding="utf-8"
    )
    import_artifact = (
        REPO_ROOT / "scripts" / "import_startup_history_artifact.sh"
    ).read_text(encoding="utf-8")

    assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in run_generator
    assert run_generator.index("assert_stand_clean.sh") < run_generator.index(
        "run --rm --no-deps"
    )
    assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in import_artifact
    assert import_artifact.index("assert_stand_clean.sh") < import_artifact.index(
        "startup_history_artifact import"
    )


def test_reference_artifact_is_import_default_only():
    """Экспорт не может по умолчанию перезаписать эталонный мир."""
    makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
    export_script = (
        REPO_ROOT / "scripts" / "export_startup_history_artifact.sh"
    ).read_text(encoding="utf-8")
    import_script = (
        REPO_ROOT / "scripts" / "import_startup_history_artifact.sh"
    ).read_text(encoding="utf-8")

    assert "STARTUP_HISTORY_EXPORT_ARTIFACT" in makefile
    assert "STARTUP_HISTORY_IMPORT_ARTIFACT" in makefile
    assert "/tmp/clickstream-startup-history.json" in export_script
    assert "data/startup_history/reference-world.json.xz" in import_script


def test_console_continue_checks_state_or_clean_stand_before_live_start():
    """Continue не стартует новый live-мир на грязном стенде без state."""
    run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
        encoding="utf-8"
    )

    assert "elif [[ \"${VERB}\" == \"continue\" ]]" in run_generator
    assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" continue" in run_generator
    assert run_generator.index("assert_stand_clean.sh\" continue") < run_generator.index(
        "up -d --build generator"
    )


def test_console_reset_checks_clean_stand_before_live_start():
    """Reset не пишет новый мир поверх старых данных."""
    run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
        encoding="utf-8"
    )
    reset_branch = run_generator.split("else\n", maxsplit=1)[1]

    assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in reset_branch
    assert reset_branch.index("assert_stand_clean.sh\" clean") < reset_branch.index(
        "up -d --build generator"
    )


def test_clean_start_paths_include_live_generator_profile():
    """Все чистые сбросы видят профильный live-генератор."""
    generated_history = (
        REPO_ROOT / "scripts" / "run_generated_history_analytics.sh"
    ).read_text(encoding="utf-8")

    assert "--profile live-generator down -v --remove-orphans" in generated_history


def test_host_clean_precheck_checks_live_before_kafka_and_stg():
    """Host backfill/import сначала проверяет, что live-генератор не запущен."""
    text = (REPO_ROOT / "scripts" / "assert_stand_clean.sh").read_text(
        encoding="utf-8"
    )

    assert "--mode live" in text
    assert text.index("--mode live") < text.index("clickhouse-client")
    assert text.index("clickhouse-client") < text.index("--mode \"${CHECK_MODE}\"")


def test_airflow_and_generator_kafka_dependency_versions_match():
    """Airflow и генератор используют одну версию kafka-python."""
    airflow_req = (REPO_ROOT / "airflow" / "requirements.txt").read_text(encoding="utf-8")
    generator_req = (REPO_ROOT / "generator" / "requirements.txt").read_text(encoding="utf-8")

    assert "kafka-python==2.0.6" in airflow_req
    assert "kafka-python==2.0.6" in generator_req
    assert "prometheus-client==0.21.1" in airflow_req
