# pyright: reportMissingImports=false
"""
test_ci_sh_worktree_exclude_2549.py — task-2549 / task-2549+1 회귀 테스트

scripts/ci.sh의 1단계 syntax check (`find $WORKSPACE -name "*.py"`)에
non-source 디렉토리 (.worktrees / .venv / venv / .codegraph-venv /
node_modules / .git) 가지치기 (prune) 가 적용되어 스캔 시간 폭증이
재발하지 않도록 박제한다.

task-2549+1 추가 박제:
- ci.sh find 명령에 `-type f` 가 적용되어 디렉토리 이름이 `*.py` 인 경우
  (예: `pkg.py/` 디렉토리) 잘못 수집되지 않는다.
- `_run_find_pruned` 헬퍼는 ci.sh 의 find 블록을 정적 텍스트 추출 후
  bash 로 실행한다. 따라서 ci.sh 가 변경되면 테스트 행동도 자동 반영된다
  (재정의 X — False Positive 방지).
"""

import os
import pathlib
import re
import subprocess

import pytest

# repo root 추적 (.../task-2549plus1-dev2)
ROOT = pathlib.Path(__file__).resolve().parents[2]
CI_SH = ROOT / "scripts" / "ci.sh"


# ---------------------------------------------------------------------------
# ci.sh 의 find 블록을 자동 추출 — 테스트가 ci.sh 변경에 자동 반영되도록 강제
# ---------------------------------------------------------------------------

_FIND_BLOCK_RE = re.compile(
    r"done\s*<\s*<\(\s*(?P<find_cmd>find\s+\"\$WORKSPACE\".*?-print0[^)]*?)\)",
    re.DOTALL,
)


def _extract_find_command(ci_sh_path: pathlib.Path = CI_SH) -> str:
    """ci.sh 의 1단계 find 명령을 텍스트로 추출.

    ci.sh 의 `done < <(find "$WORKSPACE" ... -print0 ...)` 블록을 매칭해
    안의 find 명령을 반환한다. ci.sh 가 바뀌면 자동으로 새 명령을 반환.
    """
    content = ci_sh_path.read_text()
    match = _FIND_BLOCK_RE.search(content)
    if not match:
        raise RuntimeError(
            "ci.sh 의 1단계 find 블록(`done < <(find \"$WORKSPACE\" ... -print0 ...)`)"
            "을 찾을 수 없음. ci.sh 구조 변경 시 본 헬퍼도 갱신 필요."
        )
    return match.group("find_cmd").strip()


EXPECTED_PRUNE_NAMES = [
    ".worktrees",
    ".venv",
    "venv",
    ".codegraph-venv",
    "node_modules",
    ".git",
]


# ---------------------------------------------------------------------------
# Source 검증 — prune 6종 + -type f 가 ci.sh 1단계 find 블록에 박혀 있어야 함
# ---------------------------------------------------------------------------


def test_ci_sh_exists():
    """scripts/ci.sh가 워크트리 루트에 존재한다."""
    assert CI_SH.exists(), f"ci.sh not found at {CI_SH}"


def test_ci_sh_still_finds_py_files():
    """ci.sh가 여전히 *.py 패턴을 find한다 (regression 방향 검증)."""
    content = CI_SH.read_text()
    assert re.search(r"-name\s+[\"']\*\.py[\"']", content), (
        "find pattern '*.py' missing"
    )


def test_ci_sh_uses_prune_optimization():
    """ci.sh find 블록이 -prune 가지치기 최적화를 사용한다."""
    cmd = _extract_find_command()
    assert "-prune" in cmd, (
        "ci.sh find 블록이 -prune 최적화를 사용하지 않음 — `-not -path` 회귀 위험"
    )


def test_ci_sh_uses_type_f_filter():
    """ci.sh find 블록이 -type f 로 파일만 대상 (task-2549+1 박제).

    디렉토리 이름이 `*.py` 로 끝나는 경우 (예: 패키지 디렉토리 'pkg.py/')
    py_compile 단계에 디렉토리가 전달되어 실패하거나 무의미한 결과를
    만드는 회귀를 방지한다.
    """
    cmd = _extract_find_command()
    assert re.search(r"-type\s+f", cmd), (
        "ci.sh find 블록이 -type f 필터를 사용하지 않음 — "
        "디렉토리명 *.py 매칭 회귀 위험 (task-2549+1)"
    )


@pytest.mark.parametrize("dir_name", EXPECTED_PRUNE_NAMES)
def test_ci_sh_prunes_vendor_dir(dir_name: str):
    """ci.sh가 vendor 디렉토리 6종 각각을 -path 패턴으로 매칭한다."""
    cmd = _extract_find_command()
    pat = re.compile(
        r"""[\"']\*/""" + re.escape(dir_name) + r"""(?:/\*)?[\"']"""
    )
    assert pat.search(cmd), (
        f"vendor path '*/{dir_name}' 패턴이 ci.sh find 블록에 없음 — "
        f"prune/exclude 누락 시 442,775 파일 스캔 회귀 위험"
    )


# ---------------------------------------------------------------------------
# 행동 검증 — ci.sh 에서 추출한 실제 find 명령으로 가짜 workspace 스캔
# ---------------------------------------------------------------------------


@pytest.fixture
def fake_workspace(tmp_path):
    """가짜 workspace: 정상 .py 3개 + 6종 vendor 디렉토리 .py 다수."""
    ws = tmp_path / "ws"
    ws.mkdir()

    # 정상 영역 (수집되어야 함)
    (ws / "src").mkdir()
    (ws / "src" / "a.py").write_text("print('a')\n")
    (ws / "src" / "b.py").write_text("print('b')\n")
    (ws / "tests").mkdir()
    (ws / "tests" / "test_x.py").write_text("def test_x(): pass\n")

    # .worktrees/ — 가지치기 대상 (재귀)
    (ws / ".worktrees" / "task-A" / "src").mkdir(parents=True)
    (ws / ".worktrees" / "task-A" / "src" / "x.py").write_text("pass\n")
    (ws / ".worktrees" / "task-B" / "deep" / "nested").mkdir(parents=True)
    (ws / ".worktrees" / "task-B" / "deep" / "nested" / "z.py").write_text(
        "pass\n"
    )

    # .venv/ — 가지치기 대상
    (ws / ".venv" / "lib").mkdir(parents=True)
    (ws / ".venv" / "lib" / "pkg.py").write_text("pass\n")
    (ws / ".venv" / "site.py").write_text("pass\n")

    # 중첩 venv/ (no-dot) — 가지치기 대상 (jaaz-app 사례)
    (ws / "tools" / "app" / "server" / "venv" / "lib").mkdir(parents=True)
    (ws / "tools" / "app" / "server" / "venv" / "lib" / "vendor.py").write_text(
        "pass\n"
    )
    (ws / "tools" / "app" / "server" / "venv" / "boot.py").write_text("pass\n")

    # .codegraph-venv/ — 가지치기 대상 (scripts/.codegraph-venv 사례)
    (ws / "scripts" / ".codegraph-venv" / "lib").mkdir(parents=True)
    (ws / "scripts" / ".codegraph-venv" / "lib" / "pkg.py").write_text("pass\n")

    # node_modules/ — 가지치기 대상
    (ws / "node_modules" / "pkg").mkdir(parents=True)
    (ws / "node_modules" / "pkg" / "bridge.py").write_text("pass\n")
    (ws / "frontend" / "node_modules" / "x").mkdir(parents=True)
    (ws / "frontend" / "node_modules" / "x" / "y.py").write_text("pass\n")

    # .git/ — 가지치기 대상
    (ws / ".git" / "hooks").mkdir(parents=True)
    (ws / ".git" / "hooks" / "pre.py").write_text("pass\n")

    return ws


def _run_find_pruned(workspace: pathlib.Path) -> list[str]:
    """ci.sh 에서 추출한 find 명령을 그대로 bash 로 실행 — 재정의 X.

    ci.sh 의 `"$WORKSPACE"` 만 가짜 workspace 경로로 치환하고 나머지
    인자/순서는 ci.sh 와 동일하다. 따라서 ci.sh 가 -type f / prune /
    vendor 목록을 변경하면 본 헬퍼 행동도 자동 반영된다.
    """
    cmd_template = _extract_find_command()
    # bash 환경에 WORKSPACE 변수만 export 하여 ci.sh 의 `"$WORKSPACE"` 그대로 사용
    env = os.environ.copy()
    env["WORKSPACE"] = str(workspace)
    result = subprocess.run(
        ["bash", "-c", cmd_template],
        capture_output=True,
        check=True,
        env=env,
    )
    return [p.decode("utf-8") for p in result.stdout.split(b"\0") if p]


def _run_find_unfiltered(workspace: pathlib.Path) -> list[str]:
    """수정 前 동작 (prune 없음) — 비교 베이스라인용. ci.sh와 무관."""
    cmd = ["find", str(workspace), "-name", "*.py", "-print0"]
    result = subprocess.run(cmd, capture_output=True, check=True)
    return [p.decode("utf-8") for p in result.stdout.split(b"\0") if p]


def test_find_block_extracted_from_ci_sh():
    """헬퍼가 ci.sh 의 실제 find 블록을 추출한다 — 재정의 X 어설션."""
    cmd = _extract_find_command()
    # ci.sh 의 핵심 토큰들이 추출된 명령 안에 모두 포함되어 있어야 한다
    assert cmd.startswith("find"), f"find 명령으로 시작하지 않음: {cmd!r}"
    assert "\"$WORKSPACE\"" in cmd, "$WORKSPACE 변수가 추출 명령에 없음"
    assert "-print0" in cmd, "-print0 종단이 없음"
    assert "-prune" in cmd, "-prune 가 추출 명령에 없음"
    assert re.search(r"-type\s+f", cmd), "-type f 가 추출 명령에 없음"
    assert re.search(r"-name\s+[\"']\*\.py[\"']", cmd), "-name '*.py' 가 없음"


def test_find_prunes_all_vendor_dirs_in_fake_workspace(fake_workspace):
    """prune 적용 시 6종 vendor 디렉토리 내부 .py 0건."""
    files = _run_find_pruned(fake_workspace)
    for f in files:
        for blocked in EXPECTED_PRUNE_NAMES:
            assert f"/{blocked}/" not in f, (
                f"{blocked} 내부 파일 누설: {f}"
            )


def test_find_collects_normal_py_files(fake_workspace):
    """prune 적용해도 정상 영역 .py 3개 (src/a, src/b, tests/test_x)는 수집된다."""
    files = _run_find_pruned(fake_workspace)
    rel = {os.path.relpath(f, fake_workspace) for f in files}
    expected = {"src/a.py", "src/b.py", "tests/test_x.py"}
    assert expected.issubset(rel), (
        f"정상 .py 누락 — 기대 {expected} ⊆ 실제 {rel}"
    )
    assert len(files) == 3, (
        f"정상 .py 정확히 3개여야 하는데 실제 {len(files)}: {files}"
    )


def test_prune_significantly_reduces_count(fake_workspace):
    """prune 적용 후 카운트가 비적용 대비 4배 이상 감소."""
    pruned = _run_find_pruned(fake_workspace)
    unfiltered = _run_find_unfiltered(fake_workspace)
    assert len(unfiltered) >= 12, (
        f"unfiltered count too low: {len(unfiltered)} — fixture broken"
    )
    assert len(pruned) <= 3, (
        f"pruned count too high: {len(pruned)} — prune not effective"
    )
    assert len(unfiltered) / max(len(pruned), 1) >= 4, (
        f"reduction ratio too low: {len(unfiltered)} → {len(pruned)}"
    )


def test_nested_venv_pruned(fake_workspace):
    """중첩된 venv (jaaz-app 사례) 도 확실히 가지치기된다."""
    files = _run_find_pruned(fake_workspace)
    for f in files:
        assert "tools/app/server/venv/" not in f, (
            f"중첩 venv 파일 누설: {f}"
        )


def test_nested_node_modules_pruned(fake_workspace):
    """중첩된 node_modules (frontend/) 도 가지치기된다."""
    files = _run_find_pruned(fake_workspace)
    for f in files:
        assert "frontend/node_modules/" not in f, (
            f"중첩 node_modules 파일 누설: {f}"
        )


def test_filenames_with_newline_safe(tmp_path):
    """파일명에 줄바꿈이 있어도 null-delimited 파싱으로 정확히 분리된다."""
    ws = tmp_path / "ws"
    ws.mkdir()
    (ws / "normal.py").write_text("pass\n")
    weird_name = "weird\nname.py"
    (ws / weird_name).write_text("pass\n")

    files = _run_find_pruned(ws)
    assert len(files) == 2, (
        f"줄바꿈 포함 파일명 분리 실패: 기대 2개, 실제 {len(files)}: {files}"
    )
    names = {os.path.basename(f) for f in files}
    assert names == {"normal.py", weird_name}, (
        f"파일명 깨짐: {names}"
    )


# ---------------------------------------------------------------------------
# task-2549+1 추가 박제: `-type f` 가 디렉토리명 *.py 매칭 회귀를 catch
# ---------------------------------------------------------------------------


def test_directory_named_dot_py_is_not_collected(tmp_path):
    """디렉토리 이름이 `*.py` 인 경우 ci.sh find 가 수집하지 않는다.

    `-type f` 없이 `-name "*.py"` 만 사용하면 디렉토리 이름이 `pkg.py/` 같이
    `.py` 로 끝나는 경우도 매칭되어 py_compile 에 디렉토리가 전달된다.
    task-2549+1 박제: 디렉토리는 결코 수집되지 않아야 한다.
    """
    ws = tmp_path / "ws"
    ws.mkdir()

    # 디렉토리 이름이 *.py 패턴과 매칭됨
    (ws / "fake_pkg.py").mkdir()
    (ws / "fake_pkg.py" / "inside.txt").write_text("not python\n")
    (ws / "weird_dir.py").mkdir()
    # 실제 파일 (수집되어야 함)
    (ws / "real_module.py").write_text("print('real')\n")

    files = _run_find_pruned(ws)

    # 디렉토리는 결과에 없어야 한다 (-type f 박제)
    for f in files:
        assert pathlib.Path(f).is_file(), (
            f"디렉토리가 수집됨 (`-type f` 누락 회귀): {f}"
        )
        assert not f.endswith("fake_pkg.py"), (
            f"디렉토리 'fake_pkg.py' 가 수집됨: {f}"
        )
        assert not f.endswith("weird_dir.py"), (
            f"디렉토리 'weird_dir.py' 가 수집됨: {f}"
        )

    # 실제 파일은 정확히 1건
    assert len(files) == 1, (
        f"실제 *.py 파일 1개 기대 (real_module.py), 실제 {len(files)}: {files}"
    )
    assert pathlib.Path(files[0]).name == "real_module.py"


def test_ci_sh_change_is_auto_reflected(tmp_path):
    """ci.sh find 블록이 바뀌면 헬퍼가 자동 반영한다 (재정의 X 박제).

    ci.sh 가 새 vendor 디렉토리를 prune 에 추가하거나 -type f 를 제거하면
    `_run_find_pruned` 의 출력도 자동으로 그 변경을 따라가야 한다.
    """
    # 가짜 ci.sh 작성: prune 없이 단순 find (회귀 시나리오)
    fake_ci = tmp_path / "fake_ci.sh"
    fake_ci.write_text(
        '#!/usr/bin/env bash\n'
        'PY_FILES=()\n'
        'while IFS= read -r -d \'\' f; do\n'
        '    PY_FILES+=("$f")\n'
        'done < <(find "$WORKSPACE" -type f -name "*.py" -print0 2>/dev/null)\n'
    )

    # 헬퍼가 가짜 ci.sh 의 find 블록을 추출하는지 검증
    extracted = _extract_find_command(fake_ci)
    assert "-type f" in extracted
    assert "-prune" not in extracted, (
        "가짜 ci.sh 에 -prune 없는데 추출 결과에 포함됨 — 추출이 정적이지 않음"
    )

    # 가짜 ci.sh 의 find 명령을 그대로 실행하면 prune 동작이 적용되지 않는다
    ws = tmp_path / "ws"
    (ws / ".worktrees" / "leak").mkdir(parents=True)
    (ws / ".worktrees" / "leak" / "should_leak.py").write_text("pass\n")
    (ws / "real.py").write_text("pass\n")

    env = os.environ.copy()
    env["WORKSPACE"] = str(ws)
    result = subprocess.run(
        ["bash", "-c", extracted],
        capture_output=True,
        check=True,
        env=env,
    )
    leaked = [p.decode("utf-8") for p in result.stdout.split(b"\0") if p]
    # 가짜 ci.sh 에는 prune 이 없으므로 .worktrees/leak/should_leak.py 가 누설되어야 한다
    assert any("should_leak.py" in f for f in leaked), (
        "가짜 ci.sh (prune 없음) 실행 결과에 누설 파일이 안 보임 — "
        "추출/실행 경로가 ci.sh 와 분리되어 있음"
    )
