"""테스트 fixture용 production-data 보호 가드.

2026-04-27 hotfix: pytest fixture가 `Base.metadata.drop_all(engine)` /
`shutil.rmtree(settings.projects_dir)`로 production DB와 projects/ 디렉토리를
삭제한 사고 직후 도입. 모든 destructive fixture는 호출 전 `assert_test_engine`
+ `assert_temp_dir`로 강제 검증해야 한다.

guard가 fail하면 RuntimeError를 즉시 raise하여 fixture가 production을 건드리지
못하게 한다.
"""
from __future__ import annotations

import tempfile
from pathlib import Path
from typing import Any


# 안전한 test DB database name 패턴 (allowlist).
# 사용자 메모리 룰 `feedback_postgresql_not_sqlite.md`: 운영 DB는 PostgreSQL.
# blocklist 방식은 새 production-like DB가 추가될 때 매번 갱신해야 해서 위험.
# allowlist 패턴: db name이 `_test` suffix이거나, sqlite memory/file path만 허용.
# 모든 다른 이름(theroad, theroad2, theroad_refactor_*, 신규 production DB 등)은 reject.
_TEST_DB_NAME_SUFFIXES = ("_test",)
_TEST_DB_NAME_LITERALS = {":memory:"}

# 안전한 test path prefix — system tempdir 또는 임시 sub-tree만 허용.
# macOS는 /var/folders와 /private/var/folders가 동일 위치 (Path.resolve()는 /private/ 추가)
# 이므로 둘 다 허용해야 false negative 방지.
_TEMP_ROOTS: tuple[str, ...] = (
    tempfile.gettempdir(),
    str(Path(tempfile.gettempdir()).resolve()),
    "/tmp",
    "/private/tmp",
    "/var/folders",
    "/private/var/folders",
)


def _is_safe_test_db_name(db_name: str) -> bool:
    """db_name이 test 전용 패턴이면 True. 그 외 모두 False (production 추정)."""
    if not db_name:
        return False
    if db_name in _TEST_DB_NAME_LITERALS:
        return True
    if db_name.endswith(_TEST_DB_NAME_SUFFIXES):
        return True
    # SQLite는 db_name이 파일 path거나 in-memory: tempdir 하위 또는 :memory: 만 허용
    if db_name == ":memory:" or "/tmp/" in db_name or "/var/folders/" in db_name:
        return True
    return False


def assert_test_engine(engine: Any) -> None:
    """engine이 test 전용 DB를 가리키는지 검증 (allowlist 방식).

    `Base.metadata.drop_all(engine)` 호출 전에 반드시 호출. db name이 test
    전용 패턴(`_test` suffix, `:memory:`, tempdir sqlite path)에 매치 안 되면
    production 추정 → RuntimeError raise.

    blocklist 방식보다 안전: 새 production DB(`theroad_refactor_*` 등) 추가 시
    자동으로 reject됨. test 전용 DB는 명시적으로 `_test` 같은 suffix를 가져야 함.
    """
    url = getattr(engine, "url", None)
    if url is None:
        raise RuntimeError(f"engine has no url attribute: {engine!r}")
    db_name = (getattr(url, "database", "") or "").strip("/")
    if not _is_safe_test_db_name(db_name):
        raise RuntimeError(
            f"REFUSING destructive metadata.drop_all on non-test database: "
            f"database={db_name!r}, url={url!r}. "
            f"Tests must use a database name ending in '_test' or sqlite memory/tempdir. "
            f"This guard prevents production data loss (2026-04-27 사고 회귀 가드)."
        )


def assert_temp_dir(path: Any) -> None:
    """path가 system tempdir 하위인지 강제 검증.

    `shutil.rmtree(path)` 호출 전에 반드시 호출. tempdir 외부면 RuntimeError raise.
    """
    p = Path(path).resolve()
    s = str(p)
    if not any(s == root or s.startswith(root.rstrip("/") + "/") for root in _TEMP_ROOTS):
        raise RuntimeError(
            f"REFUSING destructive rmtree on non-tempdir path: {p}. "
            f"Allowed tempdir roots: {_TEMP_ROOTS}. "
            f"This guard prevents production projects/ deletion (2026-04-27 사고 회귀 가드)."
        )


def safe_drop_all(engine: Any, base_metadata: Any) -> None:
    """assert_test_engine 검증 후 **행을 비우고 스키마는 남긴다.**

    ★이름은 그대로 두되 하는 일을 고쳤다 (2026-08-30). 호출부 17곳이
    바라는 것은 「깨끗한 DB」이지 「테이블이 없는 DB」가 아니다.

    ## 무엇이 깨져 있었나

    이 함수는 ``base_metadata.drop_all`` 로 **공유** ``theroad_test`` 의 ORM
    테이블 22개를 통째로 지웠다. 그런데 스키마를 세우는
    ``tests/conftest.py::_ensure_d6_test_db_schema`` 는 **session-scope 1회**라
    뒤에 일어난 drop 을 못 메운다. 그래서 이 함수를 쓴 파일 **뒤에 오는 모든
    시험**이 없는 테이블을 만났다 —

        UndefinedTable: relation "scene_still" does not exist
        UndefinedTable: relation "world_guide" does not exist
        UndefinedTable: table "scene_plan" does not exist

    한 번 걷기에서 **매번 다른 테이블**이 나온 이유는 그 시점에 어느 파일이
    먼저 돌았느냐가 달라서다. 파일 하나만 돌리면 안 나고 합산에서만 난다 —
    그래서 「내 수정 탓인가」를 여러 판 헛짚게 만든다.

    ## 그래서

    `drop_all` 대신 **TRUNCATE ... RESTART IDENTITY CASCADE** 로 행만 비운다.
    각 시험이 바라던 격리는 그대로이고, 스키마는 남아 뒤 시험이 안 죽는다.
    ★``step_run`` 처럼 ORM 밖에서 raw SQL 로 만드는 테이블은 metadata 에
    없어 예전에도 안 지워졌다 — 그 동작을 바꾸지 않는다.
    """
    assert_test_engine(engine)
    tables = list(base_metadata.sorted_tables)
    if not tables:
        return
    from sqlalchemy import inspect as _inspect
    from sqlalchemy import text as _text

    # ★실재하는 것만 비운다. 아직 안 만들어진 테이블이 섞이면 통째로 실패한다.
    present = set(_inspect(engine).get_table_names())
    target = [t for t in tables if t.name in present]
    if not target:
        return

    if engine.dialect.name != "postgresql":
        # ★가드는 sqlite memory 도 허용한다(`_is_safe_test_db_name`).
        #  거기서는 `TRUNCATE … CASCADE` 가 없다 — FK 를 거스르지 않게
        #  **의존 역순**으로 지운다.
        with engine.begin() as conn:
            for t in reversed(target):
                conn.execute(t.delete())
        return

    quoted = ", ".join(f'"{t.name}"' for t in target)
    with engine.begin() as conn:
        conn.execute(_text(
            f"TRUNCATE TABLE {quoted} RESTART IDENTITY CASCADE"))


def safe_rmtree(path: Any, ignore_errors: bool = True) -> None:
    """assert_temp_dir 검증 후 rmtree 실행."""
    import shutil
    p = Path(path)
    if not p.exists():
        return
    assert_temp_dir(p)
    shutil.rmtree(p, ignore_errors=ignore_errors)
