"""TDD RED Phase 3 테스트 모음.

대상 모듈 (아직 미구현):
  - orchestrator.team_lock   : fcntl.flock 기반 팀별 배타적 접근 제어
  - orchestrator.auto_orch   : CLI 오케스트레이터 핵심 함수들

작성자 : 헤임달 (dev2-team tester)
날짜   : 2026-03-24
"""

import copy
import json
import multiprocessing
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, call, patch

import pytest

# ---------------------------------------------------------------------------
# sys.path 세팅
# ---------------------------------------------------------------------------
WORKSPACE_ROOT = "/home/jay/workspace"
if WORKSPACE_ROOT not in sys.path:
    sys.path.insert(0, WORKSPACE_ROOT)

# ---------------------------------------------------------------------------
# 미구현 모듈 import  (RED 단계: ImportError 발생이 정상)
# ---------------------------------------------------------------------------
from orchestrator.auto_orch import (  # noqa: E402
    acquire_global_lock,
    cmd_list,
    cmd_run,
    cmd_scan,
    cmd_status,
    cmd_validate,
    dispatch_step,
    get_pipeline_state,
    load_pipeline,
    release_global_lock,
    save_pipeline_state,
    scan_events,
    update_health,
)
from orchestrator.team_lock import TeamLock  # noqa: E402

# ---------------------------------------------------------------------------
# Shared Fixture — 유효한 파이프라인 YAML dict
# ---------------------------------------------------------------------------
VALID_PIPELINE: dict = {
    "schema_version": "1.0",
    "id": "test-pipeline-001",
    "name": "Test Pipeline Phase 3",
    "allowed_teams": ["dev1-team", "dev2-team"],
    "token_budget": 8000,
    "blast_radius": "team",
    "gates": [
        {
            "id": "gate-1",
            "type": "qc_review",
            "approver_role": "qc_officer",
            "timeout_hours": 48,
        }
    ],
    "triggers": {"manual": True, "event": "pipeline-001.done"},
    "steps": [
        {
            "id": "step-1",
            "name": "Step 1",
            "target_team": "dev1-team",
            "task_file_template": "pipelines/templates/test.md",
            "timeout_minutes": 60,
        },
        {
            "id": "step-2",
            "name": "Step 2",
            "target_team": "dev2-team",
            "task_file_template": "pipelines/templates/test2.md",
            "depends_on": ["step-1"],
            "timeout_minutes": 60,
        },
    ],
}


# ===========================================================================
# T4. TeamLock 동시 접근 테스트
# ===========================================================================


def _worker_try_acquire(args: tuple) -> bool:
    """multiprocessing 워커: TeamLock 획득 시도 결과를 반환한다."""
    locks_dir, team_id, result_queue_path = args
    import sys

    if WORKSPACE_ROOT not in sys.path:
        sys.path.insert(0, WORKSPACE_ROOT)
    from orchestrator.team_lock import TeamLock as _TL

    try:
        with _TL(team_id, locks_dir=locks_dir):
            # 락 획득 성공 — 잠시 점유
            import time

            time.sleep(0.3)
        acquired = True
    except BlockingIOError:
        acquired = False
    return acquired


class TestTeamLock:
    """TeamLock 클래스 테스트 스위트."""

    def test_teamlock_instantiation(self, tmp_path):
        """TeamLock(team_id, locks_dir)으로 인스턴스를 생성할 수 있어야 한다."""
        lock = TeamLock("dev1-team", locks_dir=str(tmp_path))
        assert lock is not None, "TeamLock 인스턴스 생성 실패"

    def test_teamlock_context_manager_acquires_and_releases(self, tmp_path):
        """TeamLock을 context manager로 사용하면 획득 후 정상 해제되어야 한다."""
        with TeamLock("dev2-team", locks_dir=str(tmp_path)) as tl:
            assert tl is not None, "__enter__ 반환값이 None임"
        # with 블록 종료 후 락 파일이 존재하더라도 잠금은 해제되어야 한다.
        lock_file = tmp_path / "dev2-team.lock"
        # 해제 후 동일 팀으로 다시 획득 가능한지 확인
        with TeamLock("dev2-team", locks_dir=str(tmp_path)):
            pass  # 예외 없이 획득되어야 함

    def test_teamlock_creates_lock_file(self, tmp_path):
        """TeamLock 획득 시 locks_dir에 <team_id>.lock 파일이 생성되어야 한다."""
        team_id = "dev1-team"
        with TeamLock(team_id, locks_dir=str(tmp_path)):
            lock_file = tmp_path / f"{team_id}.lock"
            assert lock_file.exists(), f"락 파일 {lock_file}이 생성되지 않음"

    def test_is_team_available_returns_true_when_unlocked(self, tmp_path):
        """락이 없을 때 is_team_available()은 True를 반환해야 한다."""
        result = TeamLock.is_team_available("free-team", locks_dir=str(tmp_path))
        assert result is True, "락이 없는 팀인데 is_team_available이 False를 반환함"

    def test_is_team_available_returns_false_when_locked(self, tmp_path):
        """다른 프로세스가 팀 락을 보유 중일 때 is_team_available()은 False를 반환해야 한다."""
        team_id = "busy-team"
        # 락 획득 상태에서 is_team_available 확인은 동일 프로세스에서 재진입 불가
        # 별도 프로세스에서 락 보유 → is_team_available 확인
        barrier = multiprocessing.Barrier(2)

        def hold_lock(locks_dir, tid, barrier):
            import sys

            if WORKSPACE_ROOT not in sys.path:
                sys.path.insert(0, WORKSPACE_ROOT)
            from orchestrator.team_lock import TeamLock as _TL

            with _TL(tid, locks_dir=locks_dir):
                barrier.wait()  # 메인 프로세스가 확인할 때까지 대기
                barrier.wait()  # 확인 완료 신호 대기

        p = multiprocessing.Process(target=hold_lock, args=(str(tmp_path), team_id, barrier))
        p.start()
        barrier.wait()  # 자식 프로세스가 락을 획득할 때까지 대기
        result = TeamLock.is_team_available(team_id, locks_dir=str(tmp_path))
        barrier.wait()  # 자식 프로세스 락 해제 허용
        p.join(timeout=5)
        assert result is False, "락 보유 중인 팀인데 is_team_available이 True를 반환함"

    def test_two_processes_only_one_acquires_lock(self, tmp_path):
        """2개 프로세스가 동시에 동일 팀 락 획득 시도 → 1개만 성공, 1개는 BlockingIOError."""
        team_id = "contested-team"
        # 두 프로세스가 경쟁: 한 프로세스가 먼저 락을 잡고 0.3초 보유
        # 다른 프로세스는 LOCK_NB이므로 즉시 BlockingIOError
        args = (str(tmp_path), team_id, None)
        with multiprocessing.Pool(processes=2) as pool:
            results = pool.map(_worker_try_acquire, [args, args])

        true_count = results.count(True)
        false_count = results.count(False)
        assert true_count == 1, f"정확히 1개 프로세스만 True여야 하는데 True={true_count}"
        assert false_count == 1, f"정확히 1개 프로세스만 False여야 하는데 False={false_count}"

    def test_teamlock_different_teams_can_be_acquired_simultaneously(self, tmp_path):
        """서로 다른 팀 락은 동시에 획득 가능해야 한다."""
        with TeamLock("dev1-team", locks_dir=str(tmp_path)):
            with TeamLock("dev2-team", locks_dir=str(tmp_path)):
                pass  # 두 락 모두 획득 가능해야 함


# ===========================================================================
# T5. 전역 flock 중복 실행 방지 테스트
# ===========================================================================


class TestGlobalLock:
    """acquire_global_lock / release_global_lock 테스트 스위트."""

    def test_acquire_global_lock_returns_fd(self, tmp_path, monkeypatch):
        """acquire_global_lock() 첫 호출은 int fd를 반환해야 한다."""
        lock_path = str(tmp_path / "test-auto-orch.lock")
        monkeypatch.setattr("orchestrator.auto_orch.GLOBAL_LOCK_PATH", lock_path)
        fd = acquire_global_lock()
        try:
            assert fd is not None, "첫 번째 acquire_global_lock()이 None을 반환함"
            assert isinstance(fd, int), f"acquire_global_lock()이 int가 아닌 {type(fd)}를 반환함"
        finally:
            if fd is not None:
                release_global_lock(fd)

    def test_acquire_global_lock_second_call_returns_none(self, tmp_path, monkeypatch):
        """acquire_global_lock() 두 번째 호출 → None을 반환해야 한다 (이미 락 보유)."""
        lock_path = str(tmp_path / "test-auto-orch2.lock")
        monkeypatch.setattr("orchestrator.auto_orch.GLOBAL_LOCK_PATH", lock_path)
        fd1 = acquire_global_lock()
        try:
            assert fd1 is not None, "첫 번째 acquire_global_lock()이 None을 반환함"
            fd2 = acquire_global_lock()
            assert fd2 is None, f"두 번째 acquire_global_lock()이 None이 아닌 {fd2}를 반환함"
        finally:
            if fd1 is not None:
                release_global_lock(fd1)

    def test_release_global_lock_allows_reacquire(self, tmp_path, monkeypatch):
        """release_global_lock() 후 동일 경로의 락을 다시 획득할 수 있어야 한다."""
        lock_path = str(tmp_path / "test-auto-orch3.lock")
        monkeypatch.setattr("orchestrator.auto_orch.GLOBAL_LOCK_PATH", lock_path)
        fd1 = acquire_global_lock()
        assert fd1 is not None
        release_global_lock(fd1)
        fd2 = acquire_global_lock()
        try:
            assert fd2 is not None, "락 해제 후 재획득이 None을 반환함"
        finally:
            if fd2 is not None:
                release_global_lock(fd2)


# ===========================================================================
# T2. --validate 유효/무효 YAML 검증
# ===========================================================================


class TestAutoOrchValidate:
    """load_pipeline + cmd_validate 테스트 스위트."""

    def test_load_pipeline_valid_yaml(self, tmp_path):
        """유효한 YAML 파일을 load_pipeline으로 로드하면 dict를 반환해야 한다."""
        import yaml

        pipeline_file = tmp_path / "valid_pipeline.yaml"
        pipeline_file.write_text(yaml.dump(VALID_PIPELINE))
        result = load_pipeline(str(pipeline_file))
        assert isinstance(result, dict), "유효한 YAML인데 dict가 아닌 타입 반환"
        assert result.get("schema_version") == "1.0"

    def test_load_pipeline_missing_schema_version_raises_or_errors(self, tmp_path):
        """schema_version 없는 YAML은 load_pipeline이 에러를 보고하거나 예외를 발생시켜야 한다."""
        import yaml

        invalid = copy.deepcopy(VALID_PIPELINE)
        del invalid["schema_version"]
        pipeline_file = tmp_path / "invalid_pipeline.yaml"
        pipeline_file.write_text(yaml.dump(invalid))
        # load_pipeline이 예외를 발생시키거나 dict를 반환 (검증은 별도)
        # 최소한 파일을 파싱할 수 있어야 한다
        result = load_pipeline(str(pipeline_file))
        assert isinstance(result, dict), "load_pipeline이 dict를 반환하지 않음"

    def test_load_pipeline_invalid_yaml_syntax_raises(self, tmp_path):
        """YAML 구문 오류가 있는 파일은 load_pipeline이 예외를 발생시켜야 한다."""
        bad_yaml = tmp_path / "bad.yaml"
        bad_yaml.write_text("key: [unclosed bracket\nfoo: bar")
        with pytest.raises(Exception):
            load_pipeline(str(bad_yaml))

    def test_cmd_validate_valid_yaml_outputs_no_error(self, tmp_path, capsys):
        """유효한 YAML에 대해 cmd_validate는 에러 없음을 출력해야 한다."""
        import yaml

        pipeline_file = tmp_path / "valid.yaml"
        pipeline_file.write_text(yaml.dump(VALID_PIPELINE))
        cmd_validate(str(pipeline_file))
        captured = capsys.readouterr()
        output = (captured.out + captured.err).lower()
        # 에러 없음을 나타내는 출력 확인
        assert (
            "error" not in output or "0 error" in output or "valid" in output or "ok" in output or "pass" in output
        ), f"유효한 YAML인데 에러 출력 발생: {captured.out}{captured.err}"

    def test_cmd_validate_missing_gates_outputs_error(self, tmp_path, capsys):
        """gates 없는 YAML에 대해 cmd_validate는 에러를 출력해야 한다."""
        import yaml

        invalid = copy.deepcopy(VALID_PIPELINE)
        del invalid["gates"]
        pipeline_file = tmp_path / "invalid_no_gates.yaml"
        pipeline_file.write_text(yaml.dump(invalid))
        cmd_validate(str(pipeline_file))
        captured = capsys.readouterr()
        output = captured.out + captured.err
        assert len(output.strip()) > 0, "무효한 YAML인데 아무 출력이 없음"
        assert (
            "gate" in output.lower() or "error" in output.lower() or "invalid" in output.lower()
        ), f"gates 에러가 출력에 없음: {output}"

    def test_cmd_validate_missing_schema_version_outputs_error(self, tmp_path, capsys):
        """schema_version 없는 YAML에 대해 cmd_validate는 에러를 출력해야 한다."""
        import yaml

        invalid = copy.deepcopy(VALID_PIPELINE)
        del invalid["schema_version"]
        pipeline_file = tmp_path / "invalid_no_schema.yaml"
        pipeline_file.write_text(yaml.dump(invalid))
        cmd_validate(str(pipeline_file))
        captured = capsys.readouterr()
        output = captured.out + captured.err
        assert (
            "schema_version" in output.lower() or "error" in output.lower() or "invalid" in output.lower()
        ), f"schema_version 에러가 출력에 없음: {output}"


# ===========================================================================
# T3. --status / --list 출력 형식
# ===========================================================================


class TestAutoOrchStatusList:
    """cmd_status + cmd_list 테스트 스위트."""

    def test_cmd_status_with_running_pipeline(self, tmp_path, monkeypatch, capsys):
        """state/*.json이 있을 때 cmd_status는 파이프라인 ID와 상태를 출력해야 한다."""
        state_dir = tmp_path / "state"
        state_dir.mkdir()
        state_data = {
            "pipeline_id": "test-pipeline-001",
            "status": "running",
            "current_step": "step-1",
            "started_at": "2026-03-24T10:00:00",
        }
        (state_dir / "test-pipeline-001.json").write_text(json.dumps(state_data))
        monkeypatch.setattr("orchestrator.auto_orch.STATE_DIR", str(state_dir))
        cmd_status()
        captured = capsys.readouterr()
        output = captured.out + captured.err
        assert "test-pipeline-001" in output, f"파이프라인 ID가 status 출력에 없음: {output}"
        assert "running" in output.lower() or "step" in output.lower(), f"상태 정보가 status 출력에 없음: {output}"

    def test_cmd_status_empty_state_dir(self, tmp_path, monkeypatch, capsys):
        """state/가 비어있을 때 cmd_status는 실행 중 파이프라인 없음을 출력해야 한다."""
        state_dir = tmp_path / "state"
        state_dir.mkdir()
        monkeypatch.setattr("orchestrator.auto_orch.STATE_DIR", str(state_dir))
        cmd_status()
        captured = capsys.readouterr()
        output = (captured.out + captured.err).lower()
        # 빈 상태에서 최소한 뭔가 출력해야 함 (에러 없이)
        assert len(output.strip()) >= 0  # 에러 없이 실행되면 OK

    def test_cmd_list_with_pipelines(self, tmp_path, monkeypatch, capsys):
        """pipelines/*.yaml이 있을 때 cmd_list는 파이프라인 목록을 출력해야 한다."""
        import yaml

        pipelines_dir = tmp_path / "pipelines"
        pipelines_dir.mkdir()
        pipeline_data = copy.deepcopy(VALID_PIPELINE)
        (pipelines_dir / "test-pipeline-001.yaml").write_text(yaml.dump(pipeline_data))
        monkeypatch.setattr("orchestrator.auto_orch.PIPELINES_DIR", str(pipelines_dir))
        cmd_list()
        captured = capsys.readouterr()
        output = captured.out + captured.err
        assert (
            "test-pipeline-001" in output or "test-pipeline" in output.lower()
        ), f"파이프라인 목록이 list 출력에 없음: {output}"

    def test_cmd_list_empty_pipelines_dir(self, tmp_path, monkeypatch, capsys):
        """pipelines/가 비어있을 때 cmd_list는 에러 없이 실행되어야 한다."""
        pipelines_dir = tmp_path / "pipelines"
        pipelines_dir.mkdir()
        monkeypatch.setattr("orchestrator.auto_orch.PIPELINES_DIR", str(pipelines_dir))
        cmd_list()  # 예외 없이 실행되어야 함
        captured = capsys.readouterr()
        # 에러가 없으면 OK


# ===========================================================================
# T1. --scan dry-run 단위 테스트
# ===========================================================================


class TestAutoOrchScan:
    """cmd_scan + scan_events + dispatch_step 테스트 스위트."""

    def test_scan_events_finds_done_files(self, tmp_path):
        """incoming/에 .done 파일이 있을 때 scan_events는 해당 파일명을 반환해야 한다."""
        incoming = tmp_path / "incoming"
        processed = tmp_path / "processed"
        incoming.mkdir()
        processed.mkdir()
        (incoming / "pipeline-001.done").write_text("done")
        (incoming / "pipeline-002.done").write_text("done")
        (incoming / "not-a-done-file.txt").write_text("ignore")

        results = scan_events(str(incoming), str(processed))
        assert "pipeline-001.done" in results, f"pipeline-001.done이 scan 결과에 없음: {results}"
        assert "pipeline-002.done" in results, f"pipeline-002.done이 scan 결과에 없음: {results}"
        assert "not-a-done-file.txt" not in results, ".done이 아닌 파일이 scan 결과에 포함됨"

    def test_scan_events_excludes_already_processed(self, tmp_path):
        """processed/에 이미 있는 .done 파일은 scan_events 결과에서 제외되어야 한다."""
        incoming = tmp_path / "incoming"
        processed = tmp_path / "processed"
        incoming.mkdir()
        processed.mkdir()
        (incoming / "pipeline-new.done").write_text("done")
        (processed / "pipeline-old.done").write_text("done")

        results = scan_events(str(incoming), str(processed))
        assert "pipeline-new.done" in results, "신규 .done 파일이 결과에 없음"
        assert "pipeline-old.done" not in results, "이미 처리된 .done 파일이 결과에 포함됨"

    def test_dispatch_step_calls_subprocess_run(self, tmp_path):
        """dispatch_step은 내부적으로 subprocess.run을 호출해야 한다."""
        step = {
            "id": "step-1",
            "name": "Test Step",
            "target_team": "dev1-team",
            "task_file_template": "pipelines/templates/test.md",
            "timeout_minutes": 60,
        }
        state = {
            "pipeline_id": "test-pipeline-001",
            "status": "running",
            "current_step": "step-1",
        }
        with patch("orchestrator.auto_orch.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=0)
            dispatch_step(step, "test-pipeline-001", state)
            assert mock_run.called, "dispatch_step이 subprocess.run을 호출하지 않음"

    def test_scan_events_returns_list(self, tmp_path):
        """scan_events는 항상 list를 반환해야 한다."""
        incoming = tmp_path / "incoming"
        processed = tmp_path / "processed"
        incoming.mkdir()
        processed.mkdir()
        result = scan_events(str(incoming), str(processed))
        assert isinstance(result, list), f"scan_events 반환 타입이 list가 아님: {type(result)}"

    def test_save_and_get_pipeline_state_roundtrip(self, tmp_path, monkeypatch):
        """save_pipeline_state + get_pipeline_state 라운드트립이 올바르게 동작해야 한다."""
        state_dir = tmp_path / "state"
        state_dir.mkdir()
        monkeypatch.setattr("orchestrator.auto_orch.STATE_DIR", str(state_dir))

        state = {
            "pipeline_id": "test-pipeline-001",
            "status": "running",
            "current_step": "step-1",
        }
        save_pipeline_state("test-pipeline-001", state)
        loaded = get_pipeline_state("test-pipeline-001")
        assert loaded is not None, "저장 후 get_pipeline_state가 None을 반환함"
        assert loaded.get("status") == "running", f"로드된 상태가 올바르지 않음: {loaded}"

    def test_update_health_creates_health_json(self, tmp_path, monkeypatch):
        """update_health는 health.json을 생성/갱신해야 한다."""
        health_path = tmp_path / "health.json"
        monkeypatch.setattr("orchestrator.auto_orch.HEALTH_PATH", str(health_path))
        update_health(active_pipelines=2, errors=0)
        assert health_path.exists(), "update_health 후 health.json이 생성되지 않음"
        data = json.loads(health_path.read_text())
        assert data.get("active_pipelines") == 2, f"active_pipelines 값이 올바르지 않음: {data}"


# ===========================================================================
# T6. state/*.json crash recovery
# ===========================================================================


class TestCrashRecovery:
    """비정상 state JSON 파일 처리 테스트 스위트."""

    def test_get_pipeline_state_with_corrupted_json_returns_none(self, tmp_path, monkeypatch):
        """비정상 JSON 파일이 있으면 get_pipeline_state는 None 또는 초기 상태를 반환해야 한다."""
        state_dir = tmp_path / "state"
        state_dir.mkdir()
        monkeypatch.setattr("orchestrator.auto_orch.STATE_DIR", str(state_dir))
        # 손상된 JSON 파일 생성
        (state_dir / "crashed-pipeline.json").write_text("{invalid json content !!!")
        result = get_pipeline_state("crashed-pipeline")
        # None 또는 예외 없이 처리되어야 함
        assert result is None or isinstance(result, dict), f"비정상 JSON 처리 결과가 예상치 않음: {result}"

    def test_get_pipeline_state_with_empty_json_returns_none(self, tmp_path, monkeypatch):
        """빈 JSON 파일이 있으면 get_pipeline_state는 안전하게 처리해야 한다."""
        state_dir = tmp_path / "state"
        state_dir.mkdir()
        monkeypatch.setattr("orchestrator.auto_orch.STATE_DIR", str(state_dir))
        (state_dir / "empty-pipeline.json").write_text("")
        result = get_pipeline_state("empty-pipeline")
        assert result is None or isinstance(result, dict), f"빈 파일 처리 결과가 예상치 않음: {result}"

    def test_get_pipeline_state_nonexistent_returns_none(self, tmp_path, monkeypatch):
        """존재하지 않는 파이프라인 ID에 대해 get_pipeline_state는 None을 반환해야 한다."""
        state_dir = tmp_path / "state"
        state_dir.mkdir()
        monkeypatch.setattr("orchestrator.auto_orch.STATE_DIR", str(state_dir))
        result = get_pipeline_state("nonexistent-pipeline-xyz")
        assert result is None, f"존재하지 않는 pipeline인데 None이 아닌 {result}를 반환함"

    def test_get_pipeline_state_valid_json_loads_correctly(self, tmp_path, monkeypatch):
        """정상 JSON 파일은 get_pipeline_state가 올바르게 로드해야 한다."""
        state_dir = tmp_path / "state"
        state_dir.mkdir()
        monkeypatch.setattr("orchestrator.auto_orch.STATE_DIR", str(state_dir))
        state_data = {"pipeline_id": "recover-test", "status": "completed", "steps_done": 2}
        (state_dir / "recover-test.json").write_text(json.dumps(state_data))
        result = get_pipeline_state("recover-test")
        assert result is not None, "정상 JSON인데 None 반환"
        assert result.get("status") == "completed", f"status 값이 올바르지 않음: {result}"


# ===========================================================================
# T7. dispatch.py 호출 시 shell=False 확인
# ===========================================================================


class TestDispatchShellFalse:
    """dispatch_step의 subprocess.run shell=False 검증."""

    def test_dispatch_step_uses_shell_false(self, tmp_path, monkeypatch):
        """dispatch_step은 subprocess.run을 shell=False로 호출해야 한다."""
        monkeypatch.setattr("orchestrator.auto_orch.STATE_DIR", str(tmp_path / "state"))
        (tmp_path / "state").mkdir()

        step = {
            "id": "step-1",
            "name": "Security Step",
            "target_team": "dev1-team",
            "task_file_template": "pipelines/templates/test.md",
            "timeout_minutes": 60,
        }
        state = {
            "pipeline_id": "security-pipeline",
            "status": "running",
            "current_step": "step-1",
        }
        captured_kwargs: list[dict] = []

        def mock_run(*args, **kwargs):
            captured_kwargs.append(kwargs)
            return MagicMock(returncode=0)

        with patch("orchestrator.auto_orch.subprocess.run", side_effect=mock_run):
            dispatch_step(step, "security-pipeline", state)

        assert len(captured_kwargs) > 0, "subprocess.run이 호출되지 않음"
        for kwargs in captured_kwargs:
            shell_val = kwargs.get("shell", False)
            assert shell_val is False, f"shell=False가 아닌 shell={shell_val}로 subprocess.run이 호출됨"

    def test_dispatch_step_passes_list_not_string_to_subprocess(self, tmp_path, monkeypatch):
        """dispatch_step은 subprocess.run에 문자열이 아닌 리스트를 첫 번째 인자로 전달해야 한다."""
        monkeypatch.setattr("orchestrator.auto_orch.STATE_DIR", str(tmp_path / "state"))
        (tmp_path / "state").mkdir()

        step = {
            "id": "step-1",
            "name": "List Args Step",
            "target_team": "dev1-team",
            "task_file_template": "pipelines/templates/test.md",
            "timeout_minutes": 60,
        }
        state = {"pipeline_id": "list-arg-pipeline", "status": "running"}
        captured_args: list = []

        def mock_run(*args, **kwargs):
            if args:
                captured_args.append(args[0])
            elif "args" in kwargs:
                captured_args.append(kwargs["args"])
            return MagicMock(returncode=0)

        with patch("orchestrator.auto_orch.subprocess.run", side_effect=mock_run):
            dispatch_step(step, "list-arg-pipeline", state)

        if captured_args:
            first_arg = captured_args[0]
            assert isinstance(
                first_arg, list
            ), f"subprocess.run의 첫 번째 인자가 list가 아닌 {type(first_arg)}임 (shell injection 위험)"
