"""task-2726 회귀 테스트 — finish-task.sh worktree 격리 strict mode.

테스터: 모리건(Morrigan), 개발3팀
대상: FINISH_TASK_WORKTREE_STRICT feature flag 및 연관 bash 함수들

6개 회귀 케이스:
  회귀1: test_resolver_finds_scheduler_worktree
  회귀2: test_unresolved_emits_marker_no_fallback
  회귀3: test_external_dirty_zero_with_worktree_isolation
  회귀4: test_forbidden_diff_still_blocks
  회귀5: test_merge_base_scope_independent_of_local_main
  회귀6: test_flag_off_preserves_legacy
"""
from __future__ import annotations

import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path

# ---------------------------------------------------------------------------
# 경로 설정
# ---------------------------------------------------------------------------

# REPO = 이 테스트 파일 기준 repo 루트 (tests/regression/<file> → parents[2]).
#   로컬 절대경로 하드코딩 금지(WORKSPACE_ROOT_HARDCODE doctrine). 타 환경/CI 이식성 확보.
REPO = Path(__file__).resolve().parents[2]
FINISH = REPO / "scripts" / "finish-task.sh"

sys.path.insert(0, str(REPO))
from utils.dirty_registry import (  # noqa: E402,F401  # 런타임 sys.path 주입
    EXTERNAL_DIRTY_BLOCKER,
    OWN_DIRTY_FAIL,
    classify_blocker,
)


# ---------------------------------------------------------------------------
# 헬퍼: finish-task.sh 에서 bash 함수 추출 및 controlled env 실행
# ---------------------------------------------------------------------------

def extract_func(name: str) -> str:
    """finish-task.sh 에서 `name() { ... }` 함수 정의 블록을 추출."""
    src = FINISH.read_text(encoding="utf-8")
    start = src.index(f"{name}() {{")
    depth = 0
    i = src.index("{", start)
    end = None
    for j in range(i, len(src)):
        if src[j] == "{":
            depth += 1
        elif src[j] == "}":
            depth -= 1
            if depth == 0:
                end = j + 1
                break
    assert end is not None, f"함수 {name} 끝 찾기 실패"
    return src[start:end]


def run_func(
    func_name: str,
    args: list[str],
    workspace: str,
    events_dir: str,
) -> tuple[str, int]:
    """추출한 함수를 controlled WORKSPACE/EVENTS_DIR 로 실행, (stdout, rc) 반환."""
    body = extract_func(func_name)
    # 인자 중 공백 포함 시 따옴표 처리
    arg_str = " ".join(f'"{a}"' for a in args)
    harness = f"""#!/bin/bash
WORKSPACE="{workspace}"
EVENTS_DIR="{events_dir}"
{body}
{func_name} {arg_str}
"""
    with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as f:
        f.write(harness)
        hp = f.name
    try:
        r = subprocess.run(["bash", hp], capture_output=True, text=True)
        return r.stdout.strip(), r.returncode
    finally:
        os.unlink(hp)


# ---------------------------------------------------------------------------
# 회귀1: _resolve_task_worktree — 로컬 .worktrees 패턴 및 timers.json 우선순위
# ---------------------------------------------------------------------------

class TestResolverFindsSchedulerWorktree:
    """회귀1: _resolve_task_worktree 가 .worktrees/*<num>* 패턴으로 경로 echo."""

    def test_finds_local_worktree_by_number_pattern(self, tmp_path):
        """task-timers.json 없이 .worktrees/wt-9001-dev3 만 있어도 경로 반환."""
        workspace = str(tmp_path)
        events_dir = str(tmp_path / "events")
        os.makedirs(events_dir, exist_ok=True)

        # .worktrees/wt-9001-dev3 생성
        wt_path = tmp_path / ".worktrees" / "wt-9001-dev3"
        wt_path.mkdir(parents=True)

        out, rc = run_func("_resolve_task_worktree", ["task-9001"], workspace, events_dir)

        assert rc == 0
        assert out == str(wt_path), (
            f"resolver 가 {wt_path} 를 반환해야 하지만 {out!r} 반환"
        )

    def test_timers_json_worktree_path_takes_priority(self, tmp_path):
        """task-timers.json worktree_path 가 존재 시 .worktrees 패턴보다 우선."""
        workspace = str(tmp_path)
        events_dir = str(tmp_path / "events")
        os.makedirs(events_dir, exist_ok=True)

        # .worktrees 패턴 경로 (낮은 우선순위)
        wt_fallback = tmp_path / ".worktrees" / "wt-9001-dev3"
        wt_fallback.mkdir(parents=True)

        # timers.json 에 명시된 worktree_path (높은 우선순위)
        wt_explicit = tmp_path / ".worktrees" / "explicit-9001"
        wt_explicit.mkdir(parents=True)

        memory_dir = tmp_path / "memory"
        memory_dir.mkdir()
        timers = {
            "tasks": {
                "task-9001": {
                    "worktree_path": str(wt_explicit),
                }
            }
        }
        (memory_dir / "task-timers.json").write_text(
            json.dumps(timers), encoding="utf-8"
        )

        out, rc = run_func("_resolve_task_worktree", ["task-9001"], workspace, events_dir)

        assert rc == 0
        assert out == str(wt_explicit), (
            f"timers.json worktree_path 가 우선되어야 하지만 {out!r} 반환"
        )

    def test_empty_when_no_worktree_exists(self, tmp_path):
        """해당하는 worktree 가 없으면 빈 문자열 반환."""
        workspace = str(tmp_path)
        events_dir = str(tmp_path / "events")
        os.makedirs(events_dir, exist_ok=True)

        out, rc = run_func("_resolve_task_worktree", ["task-9999"], workspace, events_dir)

        assert rc == 0
        assert out == "", f"worktree 없으면 빈 문자열이어야 하지만 {out!r} 반환"


# ---------------------------------------------------------------------------
# 회귀2: _task_expects_worktree + _emit_worktree_unresolved
# ---------------------------------------------------------------------------

class TestUnresolvedEmitsMarkerNoFallback:
    """회귀2: worktree-base.json 마커 존재/부재로 기대 판정, unresolved 마커 발행."""

    def test_expects_worktree_true_when_base_json_exists(self, tmp_path):
        """worktree-base.json 존재 시 rc=0 (true)."""
        workspace = str(tmp_path)
        events_dir = str(tmp_path / "events")
        os.makedirs(events_dir, exist_ok=True)

        # 마커 파일 생성
        marker = tmp_path / "events" / "task-9002.worktree-base.json"
        marker.write_text(json.dumps({"base_sha": "deadbeef"}), encoding="utf-8")

        _, rc = run_func("_task_expects_worktree", ["task-9002"], workspace, events_dir)

        assert rc == 0, "worktree-base.json 존재 시 rc=0 (true) 이어야 함"

    def test_expects_worktree_false_when_no_marker(self, tmp_path):
        """worktree-base.json 없고 timers.json 없으면 rc=1 (false)."""
        workspace = str(tmp_path)
        events_dir = str(tmp_path / "events")
        os.makedirs(events_dir, exist_ok=True)

        _, rc = run_func("_task_expects_worktree", ["task-9002"], workspace, events_dir)

        assert rc == 1, "마커 없으면 rc=1 (false) 이어야 함"

    def test_expects_worktree_true_when_timers_has_worktree_path(self, tmp_path):
        """task-timers.json worktree_path 비어있지 않으면 rc=0 (true)."""
        workspace = str(tmp_path)
        events_dir = str(tmp_path / "events")
        os.makedirs(events_dir, exist_ok=True)

        wt_path = tmp_path / ".worktrees" / "wt-9002-dev3"
        wt_path.mkdir(parents=True)

        memory_dir = tmp_path / "memory"
        memory_dir.mkdir()
        timers = {
            "tasks": {
                "task-9002": {
                    "worktree_path": str(wt_path),
                }
            }
        }
        (memory_dir / "task-timers.json").write_text(
            json.dumps(timers), encoding="utf-8"
        )

        _, rc = run_func("_task_expects_worktree", ["task-9002"], workspace, events_dir)

        assert rc == 0, "timers.json worktree_path 존재 시 rc=0 (true) 이어야 함"

    def test_emit_worktree_unresolved_creates_marker(self, tmp_path):
        """_emit_worktree_unresolved 실행 후 마커 파일 생성 확인."""
        workspace = str(tmp_path)
        events_dir = str(tmp_path / "events")
        os.makedirs(events_dir, exist_ok=True)

        run_func(
            "_emit_worktree_unresolved",
            ["task-9002", "test-regression-reason"],
            workspace,
            events_dir,
        )

        marker_path = tmp_path / "events" / "task-9002.worktree-unresolved.json"
        assert marker_path.exists(), "worktree-unresolved.json 마커 파일이 생성되어야 함"

        data = json.loads(marker_path.read_text(encoding="utf-8"))
        assert data["classification"] == "WORKTREE_UNRESOLVED", (
            f"classification 이 WORKTREE_UNRESOLVED 이어야 하지만 {data.get('classification')}"
        )
        assert data["task_id"] == "task-9002"
        assert "reason" in data

    def test_emit_marker_with_single_quote_reason(self, tmp_path):
        """HIGH-2 회귀: reason 에 작은따옴표가 있어도 marker 생성 PASS (heredoc injection 방지).

        구 코드는 python -c 내부에 '$reason' 직접 보간 → 작은따옴표 시 SyntaxError → marker 미생성.
        sys.argv 전달로 수정 후에는 작은따옴표가 원문 그대로 보존되어야 한다.
        """
        workspace = str(tmp_path)
        events_dir = str(tmp_path / "events")
        os.makedirs(events_dir, exist_ok=True)

        tricky_reason = "git-gate: can't resolve PROJECT_PATH for 'worktree' task"
        run_func(
            "_emit_worktree_unresolved",
            ["task-9003", tricky_reason],
            workspace,
            events_dir,
        )

        marker_path = tmp_path / "events" / "task-9003.worktree-unresolved.json"
        assert marker_path.exists(), (
            "작은따옴표 포함 reason 에서도 worktree-unresolved.json 마커가 생성되어야 함"
        )
        data = json.loads(marker_path.read_text(encoding="utf-8"))
        assert data["classification"] == "WORKTREE_UNRESOLVED"
        assert data["task_id"] == "task-9003"
        assert data["reason"] == tricky_reason, (
            f"작은따옴표 reason 이 원문 그대로 보존되어야 하지만 {data.get('reason')!r}"
        )

    def test_emit_marker_with_empty_and_special_reason(self, tmp_path):
        """HIGH-2 회귀: 빈 문자열/유니코드/작은따옴표·특수문자 reason 에서도 crash 0, marker 생성·원문 보존.

        run_func 하니스가 인자를 큰따옴표로 감싸므로 command-substitution 및 백틱·큰따옴표는
        bash 레벨에서 변형된다(하니스 한계, finish-task.sh 무관). 실제 HIGH-2 벡터인 작은따옴표·세미콜론·
        퍼센트·유니코드·빈문자만으로 marker crash 0 및 원문 보존을 검증한다.
        """
        workspace = str(tmp_path)
        events_dir = str(tmp_path / "events")
        os.makedirs(events_dir, exist_ok=True)

        cases = ["", "한글 사유 100% 완료", "a'b;d=e:f", "it's a 'nested' one"]
        for idx, reason in enumerate(cases):
            tid = f"task-910{idx}"
            run_func(
                "_emit_worktree_unresolved",
                [tid, reason],
                workspace,
                events_dir,
            )
            marker_path = tmp_path / "events" / f"{tid}.worktree-unresolved.json"
            assert marker_path.exists(), (
                f"reason={reason!r} 에서도 마커가 생성되어야 함"
            )
            data = json.loads(marker_path.read_text(encoding="utf-8"))
            assert data["reason"] == reason, (
                f"reason 원문 보존 실패: 기대 {reason!r}, 실제 {data.get('reason')!r}"
            )


# ---------------------------------------------------------------------------
# 회귀3: classify_blocker — worktree 격리 시 external dirty 차단 0
# ---------------------------------------------------------------------------

class TestExternalDirtyZeroWithWorktreeIsolation:
    """회귀3: worktree 격리 시 main 누적 dirty 가 차단하지 않음을 검증."""

    def test_own_dirty_is_own_dirty_fail_not_external(self):
        """worktree 자기 변경 → OWN_DIRTY_FAIL (EXTERNAL_DIRTY_BLOCKER 아님)."""
        expected_files = ["scripts/finish-task.sh"]
        dirty_paths = ["scripts/finish-task.sh"]

        result = classify_blocker(expected_files, dirty_paths)

        assert result["classification"] != EXTERNAL_DIRTY_BLOCKER, (
            "자기 expected 파일의 dirty 는 EXTERNAL_DIRTY_BLOCKER 가 아니어야 함"
        )
        assert result["classification"] == OWN_DIRTY_FAIL, (
            f"자기 파일 dirty 는 OWN_DIRTY_FAIL 이어야 하지만 {result['classification']}"
        )

    def test_own_dirty_unrelated_is_empty_when_only_own(self):
        """자기 expected 파일만 dirty 시 unrelated_dirty 비어야 함."""
        expected_files = ["scripts/finish-task.sh"]
        dirty_paths = ["scripts/finish-task.sh"]

        result = classify_blocker(expected_files, dirty_paths)

        assert result["unrelated_dirty"] == [], (
            "자기 파일만 dirty 시 unrelated_dirty 가 [] 이어야 함"
        )

    def test_main_accumulated_dirty_becomes_external_blocker(self):
        """shared main 누적 dirty(worktree 와 무관) → EXTERNAL_DIRTY_BLOCKER.

        격리 시 이 dirty 들이 dirty_paths 에 안 들어오므로 차단 0.
        이 테스트는 '격리 덕분에 main dirty 차단 없음'의 대조 검증.
        """
        expected_files = ["scripts/finish-task.sh"]
        # worktree 밖, main 누적 dirty 모사 (1000개 패턴)
        dirty_paths = [f"memory/specs/x{i}.md" for i in range(100)] + [
            "utils/other.py",
            "docs/README.md",
            "memory/tasks/task-1234.md",
        ]

        result = classify_blocker(expected_files, dirty_paths)

        assert result["classification"] == EXTERNAL_DIRTY_BLOCKER, (
            "main 누적 dirty 가 포함되면 EXTERNAL_DIRTY_BLOCKER 여야 함 "
            "(격리 시엔 이게 dirty_paths에 안 들어와서 차단 0)"
        )
        # own_dirty 는 비어있어야 함 (자기 파일 없음)
        assert result["own_dirty"] == []

    def test_isolated_worktree_has_zero_external_blocks(self):
        """worktree 격리 시: dirty_paths 가 자기 expected 만 → EXTERNAL_DIRTY_BLOCKER 없음.

        이것이 task-2726 의 핵심: shared main 1238 dirty 가 dirty_paths 에
        들어오지 않으므로 EXTERNAL_DIRTY_BLOCKER 0건.
        """
        expected_files = ["scripts/finish-task.sh"]
        # 격리된 worktree 에서의 dirty: 자기 파일만
        dirty_paths = ["scripts/finish-task.sh"]

        result = classify_blocker(expected_files, dirty_paths)

        # EXTERNAL_DIRTY_BLOCKER 가 아님 = main dirty 차단 없음
        assert result["classification"] != EXTERNAL_DIRTY_BLOCKER, (
            "격리 시 자기 파일만 dirty 면 EXTERNAL_DIRTY_BLOCKER 가 아니어야 함"
        )
        assert result["unrelated_dirty"] == [], (
            "격리 시 unrelated_dirty 는 [] (main dirty 가 차단 안 함)"
        )


# ---------------------------------------------------------------------------
# 회귀4: 금지 diff 는 여전히 차단됨
# ---------------------------------------------------------------------------

class TestForbiddenDiffStillBlocks:
    """회귀4: worktree 내 out-of-scope/forbidden 변경 탐지 + fail-closed 원칙."""

    def test_out_of_scope_file_is_external_dirty_blocker(self):
        """expected 외 파일(terminal_state_callback.py) dirty → EXTERNAL_DIRTY_BLOCKER."""
        expected_files = ["scripts/finish-task.sh"]
        dirty_paths = ["scripts/harness/v36/terminal_state_callback.py"]

        result = classify_blocker(expected_files, dirty_paths)

        assert result["classification"] == EXTERNAL_DIRTY_BLOCKER, (
            "out-of-scope 변경은 EXTERNAL_DIRTY_BLOCKER 여야 함"
        )
        assert "scripts/harness/v36/terminal_state_callback.py" in result["unrelated_dirty"]

    def test_mixed_own_and_forbidden_detects_both(self):
        """자기 파일 + forbidden 파일 동시 dirty → OWN_DIRTY_FAIL (own 우선)."""
        expected_files = ["scripts/finish-task.sh"]
        dirty_paths = [
            "scripts/finish-task.sh",
            "scripts/harness/v36/terminal_state_callback.py",
        ]

        result = classify_blocker(expected_files, dirty_paths)

        # own 이 있으면 OWN_DIRTY_FAIL (own 우선)
        assert result["classification"] == OWN_DIRTY_FAIL
        assert "scripts/harness/v36/terminal_state_callback.py" in result["unrelated_dirty"]

    def test_fail_closed_pattern_in_source(self):
        """finish-task.sh GIT-GATE 가 dirty 시 fail-closed(exit 1) 원칙 존재 확인.

        정적 grep: '차단 유지 (fail-closed)' 또는 분류 무관 exit 1 패턴 검증.
        """
        src = FINISH.read_text(encoding="utf-8")

        # 패턴 1: 차단 유지 (fail-closed) 주석
        has_failclosed_comment = "차단 유지 (fail-closed)" in src

        # 패턴 2: dirty 블록 내 exit 1 — EXTERNAL_DIRTY_BLOCKER 분기 이후 exit 1 존재
        # 분류 결과 관계없이 exit 1 하는 라인
        dirty_block_start = src.find("REAL_DIFF")
        dirty_block_end = src.find("uncommitted 변경 없음 확인", dirty_block_start)
        if dirty_block_start > 0 and dirty_block_end > dirty_block_start:
            dirty_block = src[dirty_block_start:dirty_block_end]
            has_exit1_in_dirty_block = "exit 1" in dirty_block
        else:
            has_exit1_in_dirty_block = False

        assert has_failclosed_comment or has_exit1_in_dirty_block, (
            "GIT-GATE dirty 블록에 fail-closed(exit 1) 원칙이 존재해야 함. "
            f"failclosed_comment={has_failclosed_comment}, "
            f"exit1_in_dirty_block={has_exit1_in_dirty_block}"
        )


# ---------------------------------------------------------------------------
# 회귀5: merge-base scope 가 로컬 main 위치와 무관
# ---------------------------------------------------------------------------

class TestMergeBaseScopeIndependentOfLocalMain:
    """회귀5: scope diff 가 merge-base..HEAD 기준이면 로컬 main 위치 무관."""

    def _make_temp_git_repo(self, tmp_path: Path) -> tuple[Path, str]:
        """임시 git 저장소 생성, 커밋 2개 추가."""
        repo = tmp_path / "testrepo"
        repo.mkdir()
        env = {**os.environ, "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t.com",
               "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t.com"}

        def git(*args):
            return subprocess.run(
                ["git", "-C", str(repo)] + list(args),
                capture_output=True,
                text=True,
                env=env,
                check=True,
            )

        git("init", "-b", "main")
        git("config", "user.email", "t@t.com")
        git("config", "user.name", "test")

        # 커밋 A (base)
        (repo / "base.txt").write_text("base")
        git("add", "base.txt")
        git("commit", "-m", "base commit A")

        # base SHA 기록
        base_sha = subprocess.run(
            ["git", "-C", str(repo), "rev-parse", "HEAD"],
            capture_output=True, text=True, check=True
        ).stdout.strip()

        # 작업 브랜치 생성 후 커밋 B
        git("checkout", "-b", "task-branch")
        (repo / "work.txt").write_text("work")
        git("add", "work.txt")
        git("commit", "-m", "work commit B")

        return repo, base_sha

    def test_merge_base_returns_base_sha(self, tmp_path):
        """merge-base(base, HEAD) 가 base SHA 와 동일."""
        repo, base_sha = self._make_temp_git_repo(tmp_path)

        r = subprocess.run(
            ["git", "-C", str(repo), "merge-base", base_sha, "HEAD"],
            capture_output=True, text=True
        )
        merge_base_sha = r.stdout.strip()

        assert r.returncode == 0
        assert merge_base_sha == base_sha, (
            f"merge-base 가 base commit SHA {base_sha[:8]} 여야 하지만 {merge_base_sha[:8]}"
        )

    def test_merge_base_independent_of_local_main_position(self, tmp_path):
        """로컬 main 브랜치가 어디 있든 merge-base(base, HEAD) 결과 동일."""
        repo, base_sha = self._make_temp_git_repo(tmp_path)

        env = {**os.environ, "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t.com",
               "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t.com"}

        def git(*args):
            return subprocess.run(
                ["git", "-C", str(repo)] + list(args),
                capture_output=True, text=True, env=env
            )

        # merge-base with base_sha (고정 참조)
        mb1 = git("merge-base", base_sha, "HEAD").stdout.strip()

        # main 브랜치를 다른 위치로 옮겨도 (로컬 main 이동)
        # 여기서는 main 브랜치를 HEAD 로 이동
        git("branch", "-f", "main", "HEAD")

        # merge-base with base_sha 는 동일
        mb2 = git("merge-base", base_sha, "HEAD").stdout.strip()

        assert mb1 == mb2 == base_sha, (
            f"로컬 main 위치 무관하게 merge-base 는 항상 {base_sha[:8]} 여야 함. "
            f"mb1={mb1[:8]}, mb2={mb2[:8]}"
        )

    def test_merge_base_diff_captures_only_branch_commits(self, tmp_path):
        """merge-base..HEAD diff 가 브랜치 추가 파일만 포함."""
        repo, base_sha = self._make_temp_git_repo(tmp_path)

        r = subprocess.run(
            ["git", "-C", str(repo), "diff", "--name-only", f"{base_sha}..HEAD"],
            capture_output=True, text=True
        )
        diff_files = [f.strip() for f in r.stdout.splitlines() if f.strip()]

        assert "work.txt" in diff_files, "브랜치에서 추가한 work.txt 가 diff 에 포함되어야 함"
        assert "base.txt" not in diff_files, "base commit 의 base.txt 는 diff 에 없어야 함"


# ---------------------------------------------------------------------------
# 회귀6: strict OFF 시 레거시 경로 보존 정적 검증
# ---------------------------------------------------------------------------

class TestFlagOffPreservesLegacy:
    """회귀6: FINISH_TASK_WORKTREE_STRICT 미설정(default 0) 시 레거시 경로 보존."""

    def _get_src(self) -> str:
        return FINISH.read_text(encoding="utf-8")

    def test_default_flag_is_zero(self):
        """FINISH_TASK_WORKTREE_STRICT 기본값 0 설정 라인 존재."""
        src = self._get_src()
        assert 'FINISH_TASK_WORKTREE_STRICT="${FINISH_TASK_WORKTREE_STRICT:-0}"' in src, (
            "feature flag 기본값 0 설정 라인이 없음"
        )

    def test_legacy_scope_proj_dir_in_else_branch(self):
        """else 분기(strict OFF)에 SCOPE_PROJ_DIR=${PROJECT_PATH:-$WORKSPACE} 존재."""
        src = self._get_src()
        # else 분기 한정 검색: strict=1 블록 뒤 else 이후
        # 정규식으로 else 블록 내 패턴 찾기
        pattern = r'else\b[^}]*?SCOPE_PROJ_DIR="\$\{PROJECT_PATH:-\$WORKSPACE\}"'
        match = re.search(pattern, src, re.DOTALL)
        assert match is not None, (
            "else 분기에 SCOPE_PROJ_DIR=\"${PROJECT_PATH:-$WORKSPACE}\" 가 존재해야 함"
        )

    def test_legacy_scope_proj_dir_exactly_once_in_else(self):
        """SCOPE_PROJ_DIR=${PROJECT_PATH:-$WORKSPACE} 가 정확히 1회 존재 (else 분기)."""
        src = self._get_src()
        count = src.count('SCOPE_PROJ_DIR="${PROJECT_PATH:-$WORKSPACE}"')
        assert count == 1, (
            f"SCOPE_PROJ_DIR 레거시 할당이 정확히 1회여야 하지만 {count}회 발견"
        )

    def test_legacy_work_dir_in_else_branch(self):
        """else 분기에 WORK_DIR=${PROJECT_PATH:-$WORKSPACE} 존재."""
        src = self._get_src()
        assert 'WORK_DIR="${PROJECT_PATH:-$WORKSPACE}"' in src, (
            "else 분기에 WORK_DIR=\"${PROJECT_PATH:-$WORKSPACE}\" 가 존재해야 함"
        )

    def test_bash_syntax_check(self):
        """bash -n 으로 finish-task.sh 구문 검사 rc=0."""
        r = subprocess.run(
            ["bash", "-n", str(FINISH)],
            capture_output=True,
            text=True,
        )
        assert r.returncode == 0, (
            f"bash -n 구문 검사 실패 (rc={r.returncode}):\n{r.stderr}"
        )


# ---------------------------------------------------------------------------
# 회귀7: origin/main 하드코딩 제거 → MAIN_BRANCH(main/master) 동적 감지
# task-2726+2 round-2 bounded fix
# ---------------------------------------------------------------------------

class TestMainBranchDetectionMasterDefault:
    """회귀7: origin/main 하드코딩 제거. master-default repo 에서도 merge-base 계산 동작."""

    def _detect_main_branch(self, repo: Path) -> str:
        """finish-task.sh 의 감지 로직과 동일: 로컬 main ref 있으면 main, 없으면 master."""
        rc = subprocess.run(
            ["git", "-C", str(repo), "rev-parse", "--verify", "main"],
            capture_output=True, text=True,
        ).returncode
        return "main" if rc == 0 else "master"

    def _git_env(self):
        return {**os.environ, "GIT_AUTHOR_NAME": "test", "GIT_AUTHOR_EMAIL": "t@t.com",
                "GIT_COMMITTER_NAME": "test", "GIT_COMMITTER_EMAIL": "t@t.com"}

    def _make_repo_with_origin(self, tmp_path: Path, default_branch: str):
        """origin(bare) + clone 구성. default_branch=main|master. (clone_repo, base_sha) 반환."""
        env = self._git_env()

        def g(repo, *args):
            return subprocess.run(["git", "-C", str(repo)] + list(args),
                                  capture_output=True, text=True, env=env, check=True)

        origin = tmp_path / "origin.git"
        origin.mkdir()
        subprocess.run(["git", "init", "--bare", "-b", default_branch, str(origin)],
                       capture_output=True, text=True, env=env, check=True)

        seed = tmp_path / "seed"
        seed.mkdir()
        subprocess.run(["git", "init", "-b", default_branch, str(seed)],
                       capture_output=True, text=True, env=env, check=True)
        g(seed, "config", "user.email", "t@t.com")
        g(seed, "config", "user.name", "test")
        (seed / "base.txt").write_text("base")
        g(seed, "add", "base.txt")
        g(seed, "commit", "-m", "base commit")
        g(seed, "remote", "add", "origin", str(origin))
        g(seed, "push", "origin", default_branch)

        clone = tmp_path / "clone"
        subprocess.run(["git", "clone", str(origin), str(clone)],
                       capture_output=True, text=True, env=env, check=True)
        g(clone, "config", "user.email", "t@t.com")
        g(clone, "config", "user.name", "test")
        base_sha = subprocess.run(["git", "-C", str(clone), "rev-parse", "HEAD"],
                                  capture_output=True, text=True, check=True).stdout.strip()
        # 작업 브랜치 + 커밋
        g(clone, "checkout", "-b", "task-branch")
        (clone / "work.txt").write_text("work")
        g(clone, "add", "work.txt")
        g(clone, "commit", "-m", "work commit")
        return clone, base_sha

    def test_detect_master_when_main_absent(self, tmp_path):
        """master-default repo (로컬 main 없음) → MAIN_BRANCH=master 감지."""
        clone, _ = self._make_repo_with_origin(tmp_path, "master")
        assert self._detect_main_branch(clone) == "master"

    def test_detect_main_when_present(self, tmp_path):
        """main-default repo → MAIN_BRANCH=main 감지."""
        clone, _ = self._make_repo_with_origin(tmp_path, "main")
        assert self._detect_main_branch(clone) == "main"

    def test_merge_base_with_origin_master_when_origin_main_absent(self, tmp_path):
        """master-default repo: origin/main 부재여도 origin/${MAIN_BRANCH}=origin/master 로 merge-base PASS."""
        clone, base_sha = self._make_repo_with_origin(tmp_path, "master")
        env = self._git_env()
        # origin/main 은 존재하지 않아야 함
        rc_main = subprocess.run(["git", "-C", str(clone), "rev-parse", "origin/main"],
                                 capture_output=True, text=True, env=env).returncode
        assert rc_main != 0, "master-default repo 에 origin/main 이 있으면 안 됨"
        mb = self._detect_main_branch(clone)
        assert mb == "master"
        r = subprocess.run(["git", "-C", str(clone), "merge-base", f"origin/{mb}", "HEAD"],
                           capture_output=True, text=True, env=env)
        assert r.returncode == 0, f"origin/{mb} merge-base 실패: {r.stderr}"
        assert r.stdout.strip() == base_sha, "merge-base 가 base SHA 와 일치해야 함"

    def test_merge_base_with_origin_main_in_main_default(self, tmp_path):
        """main-default repo: origin/main 으로 merge-base PASS (기존 동작 유지)."""
        clone, base_sha = self._make_repo_with_origin(tmp_path, "main")
        env = self._git_env()
        mb = self._detect_main_branch(clone)
        assert mb == "main"
        r = subprocess.run(["git", "-C", str(clone), "merge-base", f"origin/{mb}", "HEAD"],
                           capture_output=True, text=True, env=env)
        assert r.returncode == 0
        assert r.stdout.strip() == base_sha

    def test_source_has_no_origin_main_hardcode(self):
        """finish-task.sh 코드(주석 제외)에 origin/main 리터럴 0건."""
        src = FINISH.read_text(encoding="utf-8")
        code = re.sub(r"#.*", "", src)
        assert "origin/main" not in code, "코드 경로에 origin/main 하드코딩이 남아 있음"

    def test_source_uses_origin_main_branch_var(self):
        """finish-task.sh 가 origin/${MAIN_BRANCH} 변수 형태를 사용."""
        src = FINISH.read_text(encoding="utf-8")
        assert "origin/${MAIN_BRANCH}" in src or "origin/$MAIN_BRANCH" in src, (
            "origin/${MAIN_BRANCH} 사용 패턴이 없음"
        )


# ---------------------------------------------------------------------------
# 회귀7: round-3 bounded fix — STRICT scope-gate SCOPE_BASE empty fail-closed
# ---------------------------------------------------------------------------

class TestStrictScopeBaseEmptyFailClosed:
    """회귀7: STRICT scope-gate 에서 SCOPE_BASE 미감지 시 fail-OPEN 금지, fail-closed.

    배경: merge-base(origin/${MAIN_BRANCH}, HEAD) + worktree-base.json fallback 모두
    실패해 SCOPE_BASE 가 비었을 때, 과거엔 `: > "$SCOPE_DIFF_FILE"`(빈 diff)로 scope
    검증을 스킵 = fail-OPEN 이었음. round-3 bounded fix 로 명시적 fail-closed(exit 1)로
    교체됨. 정적 소스 패턴 검증으로 회귀 방지.
    """

    @staticmethod
    def _strict_block(src: str) -> str:
        """STRICT scope-gate 블록(SCOPE_BASE merge-base ~ non-strict else 직전)을 슬라이스."""
        start = src.index('SCOPE_BASE=$(git -C "$SCOPE_PROJ_DIR" merge-base')
        # non-strict 레거시 else 분기 직전까지
        end = src.index(
            'git -C "$SCOPE_PROJ_DIR" diff --name-only "${MAIN_BRANCH}..HEAD"', start
        )
        return src[start:end]

    def test_strict_empty_scope_base_no_empty_diff_failopen(self):
        """STRICT 블록 내에 빈-diff fail-OPEN(`: > "$SCOPE_DIFF_FILE"`)이 없어야 함."""
        src = FINISH.read_text(encoding="utf-8")
        strict_block = self._strict_block(src)
        assert ': > "$SCOPE_DIFF_FILE"' not in strict_block, (
            "STRICT scope-gate SCOPE_BASE empty 분기에 빈-diff fail-OPEN "
            "(빈 diff redirect)이 잔존함 — fail-closed 로 교체되어야 함"
        )

    def test_strict_empty_scope_base_exits_failclosed(self):
        """STRICT 블록 내에 exit 1 + fail-closed 신호가 모두 존재해야 함."""
        src = FINISH.read_text(encoding="utf-8")
        strict_block = self._strict_block(src)
        assert "exit 1" in strict_block, (
            "STRICT scope-gate 블록에 exit 1(fail-closed) 이 없음"
        )
        has_failclosed_signal = (
            "fail-closed" in strict_block
            or "_emit_worktree_unresolved" in strict_block
        )
        assert has_failclosed_signal, (
            "STRICT scope-gate 블록에 fail-closed 신호('fail-closed' 문자열 또는 "
            "_emit_worktree_unresolved 호출)가 없음"
        )

    def test_strict_valid_scope_base_generates_diff(self):
        """STRICT 블록에 valid SCOPE_BASE 경로(diff --name-only "${SCOPE_BASE}..HEAD")가 보존됨."""
        src = FINISH.read_text(encoding="utf-8")
        strict_block = self._strict_block(src)
        assert 'diff --name-only "${SCOPE_BASE}..HEAD"' in strict_block, (
            "STRICT scope-gate 의 valid SCOPE_BASE diff 경로가 보존되어야 함"
        )

    def test_nonstrict_legacy_path_preserved(self):
        """non-strict 레거시 경로(${MAIN_BRANCH}..HEAD + HEAD~1 fallback)가 보존됨 (회귀3)."""
        src = FINISH.read_text(encoding="utf-8")
        assert 'diff --name-only "${MAIN_BRANCH}..HEAD"' in src, (
            "non-strict 레거시 ${MAIN_BRANCH}..HEAD 경로가 사라짐 — default-off 호환 깨짐"
        )
        assert "diff --name-only HEAD~1" in src, (
            "non-strict 레거시 HEAD~1 fallback 경로가 사라짐 — default-off 호환 깨짐"
        )

    def test_no_anu_key_literal(self):
        """ANU key 리터럴 노출 0건 (회귀9 보강)."""
        src = FINISH.read_text(encoding="utf-8")
        # ANU key 리터럴을 테스트 소스에 노출하지 않도록 분할 재구성
        _anu_key = "c119085" + "addb0f8b7"
        assert _anu_key not in src, "ANU key 리터럴이 소스에 노출됨"


# ---------------------------------------------------------------------------
# 회귀8: task-2726+4 HIGH-1 — STRICT mode non-git SCOPE_PROJ_DIR fail-closed
# ---------------------------------------------------------------------------

class TestStrictNonGitFailClosed:
    """회귀8: STRICT 에서 SCOPE_PROJ_DIR 가 git repo 아니면 fail-OPEN(스킵) 금지, fail-closed(exit 1).

    근거: PR #171 fresh HIGH-1 (line 540 non-git → 검증 스킵 = fail-open 잔여 분기).
    round-3 가 닫은 SCOPE_BASE empty 와 같은 fail-open family. 정적 소스 패턴으로 회귀 방지.
    """

    @staticmethod
    def _nongit_block(src: str) -> str:
        """non-git 분기(rev-parse --git-dir 실패 ~ SCOPE_DIFF_FILE 정의 직전) 슬라이스."""
        start = src.index('if ! git -C "$SCOPE_PROJ_DIR" rev-parse --git-dir')
        end = src.index('SCOPE_DIFF_FILE="$EVENTS_DIR', start)
        return src[start:end]

    def test_strict_nongit_exits_fail_closed(self):
        """non-git 분기에 STRICT 가드 + exit 1 + fail-closed 신호 존재."""
        src = FINISH.read_text(encoding="utf-8")
        block = self._nongit_block(src)
        assert 'FINISH_TASK_WORKTREE_STRICT" = "1"' in block, (
            "non-git 분기에 STRICT 가드가 없음"
        )
        assert "exit 1" in block, "STRICT non-git 분기에 exit 1(fail-closed) 이 없음"
        has_signal = "_emit_worktree_unresolved" in block or "fail-closed" in block
        assert has_signal, (
            "STRICT non-git 분기에 fail-closed 신호(_emit_worktree_unresolved 또는 'fail-closed')가 없음"
        )

    def test_nonstrict_nongit_preserves_skip(self):
        """non-strict(레거시)에선 'not a git repo — 검증 스킵' echo 보존."""
        src = FINISH.read_text(encoding="utf-8")
        block = self._nongit_block(src)
        assert "not a git repo — 검증 스킵" in block, (
            "non-strict 레거시 non-git SKIP echo 가 사라짐 — default-off 호환 깨짐"
        )


# ---------------------------------------------------------------------------
# 회귀9: task-2726+4 fail-open family — STRICT mode diff-empty fail-closed
# ---------------------------------------------------------------------------

class TestStrictDiffEmptyFailClosed:
    """회귀9: STRICT 에서 scope diff 비어있음 → fail-OPEN(스킵) 금지, fail-closed(exit 1).

    근거: ANU 전수 audit (line 569 'diff 비어있음 — 검증 스킵' fail-open 후보).
    STRICT 스킵 분기 전수 fail-closed 일관화. non-strict 는 SKIP 보존.
    """

    @staticmethod
    def _diffempty_block(src: str) -> str:
        """diff-empty 분기(! -s SCOPE_DIFF_FILE ~ task-scope-guard.sh 호출 직전) 슬라이스."""
        start = src.index('if [ ! -s "$SCOPE_DIFF_FILE" ]; then')
        end = src.index('task-scope-guard.sh', start)
        return src[start:end]

    def test_strict_diff_empty_exits_fail_closed(self):
        """diff-empty 분기에 STRICT 가드 + exit 1 + fail-closed 신호 존재."""
        src = FINISH.read_text(encoding="utf-8")
        block = self._diffempty_block(src)
        assert 'FINISH_TASK_WORKTREE_STRICT" = "1"' in block, (
            "diff-empty 분기에 STRICT 가드가 없음"
        )
        assert "exit 1" in block, "STRICT diff-empty 분기에 exit 1(fail-closed) 이 없음"
        has_signal = "_emit_worktree_unresolved" in block or "fail-closed" in block
        assert has_signal, "STRICT diff-empty 분기에 fail-closed 신호가 없음"

    def test_nonstrict_diff_empty_preserves_skip(self):
        """non-strict(레거시)에선 'diff 비어있음 — 검증 스킵' echo 보존."""
        src = FINISH.read_text(encoding="utf-8")
        block = self._diffempty_block(src)
        assert "diff 비어있음 — 검증 스킵" in block, (
            "non-strict 레거시 diff-empty SKIP echo 가 사라짐 — default-off 호환 깨짐"
        )


# ---------------------------------------------------------------------------
# 회귀10: task-2726+4 HIGH-2 — /home/jay 하드코딩 전수 제거
# ---------------------------------------------------------------------------

class TestNoHomeJayHardcode:
    """회귀10: finish-task.sh 코드경로에 /home/jay 리터럴 0 + env/expanduser 주입.

    근거: PR #171 fresh HIGH-2 (line 8 WORKSPACE + line 56 cokacdir glob /home/jay 하드코딩).
    WORKSPACE_ROOT_HARDCODE 계열 재발 방지.
    """

    def test_no_home_jay_literal_in_code(self):
        """주석 제외 코드에 /home/jay 리터럴 0건 (goal_assertion 과 동일 기준)."""
        src = FINISH.read_text(encoding="utf-8")
        code = "\n".join(l for l in src.splitlines() if not l.strip().startswith("#"))
        assert "/home/jay" not in code, "코드 경로에 /home/jay 하드코딩이 남아 있음"

    def test_workspace_uses_home_env_default(self):
        """WORKSPACE 가 ${WORKSPACE:-$HOME/workspace} env/default 주입 형태."""
        src = FINISH.read_text(encoding="utf-8")
        assert 'WORKSPACE="${WORKSPACE:-$HOME/workspace}"' in src, (
            "WORKSPACE env/default 주입 라인이 없음"
        )

    def test_resolver_uses_expanduser_for_cokacdir(self):
        """_resolve_task_worktree 의 .cokacdir glob 가 expanduser('~') 기반, /home/jay/.cokacdir 부재."""
        src = FINISH.read_text(encoding="utf-8")
        has_expanduser = (
            "os.path.expanduser('~')" in src or 'os.path.expanduser("~")' in src
        )
        assert has_expanduser, "expanduser('~') 사용 패턴이 없음"
        assert "/home/jay/.cokacdir" not in src, "cokacdir glob 에 /home/jay 하드코딩 잔존"
