"""task-2818 회귀 테스트 — scope-guard stale-base 오탐 근절 (양방향).

테스터: 벨레스(Veles), dev6팀

배경 (task-2818):
    finish-task.sh scope-guard 는 diff 계산 기준(base)을 잘못 잡으면 두 가지
    실패 모드를 낳는다.
      (a) stale base(예: task 분기 이전의 더 오래된 커밋)를 쓰면, task 분기
          "이전"에 main 에 머지된 무관한 변경까지 diff 에 섞여 들어와
          scope 위반으로 오탐(false positive)한다.
      (b) base 를 전혀 확정하지 못했는데 빈 diff 로 "통과"시켜 버리면
          (fail-open) 실제 위반이 있어도 조용히 스킵된다.
    task-2818 수정 후 base 해석 우선순위는 다음과 같다:
      1순위: memory/events/<task_id>.worktree-base.json 의 base_sha (authoritative)
      2순위: 없으면 git fetch origin <main> 후 merge-base(origin/<main>, HEAD)
      3순위: 그래도 없으면 fail-closed(exit 1) — 빈 diff로 조용히 PASS 금지
      + diff 계산 전 `git merge-base --is-ancestor <base> HEAD` ancestor 가드.
        base 가 HEAD 의 조상이 아니면 fail-closed.

이 테스트는:
  - 위 base 해석 로직을 (scripts/ 를 건드리지 않고) 이 테스트 파일 내부의
    독립 bash 재현 스크립트(_RESOLVE_SCOPE_BASE_SH)로 재현한다. 실제
    finish-task.sh 의 scope-guard 블록(약 807-891행)이 구현해야 하는
    사양을 그대로 인코딩한 것이며, task-scope-guard.sh 는 실제 스크립트를
    subprocess 로 직접 호출해 검증한다(가장 직접적인 부분).
  - tmp_path 에 실제 git repo(+origin bare remote)를 만들어 커밋 이력을
    재현하고, 실제 diff 계산 결과를 실제 task-scope-guard.sh 에 먹인다.

케이스:
  T1 test_stale_base_false_positive_vs_correct_base_pass
      오탐0 핵심 회귀: 올바른 base(분기점, worktree-base.json 기록)로 계산하면
      scope 내 파일만 diff 에 잡혀 PASS. 대조군으로 stale base 를 쓰면
      무관한 커밋의 파일(server/main.py)이 diff 에 섞여 위반으로 뜨는 것을
      별도로 보여준다(오탐 재현).
  T2 test_real_violation_detected_with_correct_base
      올바른 base 를 쓰더라도 task 브랜치 자체가 scope 밖 파일을 건드리면
      진짜 위반은 여전히 검출된다(FAIL + scope-violation.json).
  T3 test_fail_closed_when_base_undeterminable
      worktree-base.json 도 없고 origin fetch/merge-base 도 실패하는 상황에서
      base 해석이 fail-closed(exit 1) 하고, 빈 diff로 조용히 PASS 되는 일이
      없음을 명시적으로 검증한다.
  T4 test_ancestor_guard_rejects_non_ancestor_base
      worktree-base.json 의 base_sha 가 HEAD 의 조상이 아니면(다른 브랜치의
      커밋) ancestor 가드가 fail-closed 시킴을 검증한다.
"""
from __future__ import annotations

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

REPO = Path(__file__).resolve().parents[1]
SCOPE_GUARD_SCRIPT = REPO / "scripts" / "task-scope-guard.sh"

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

# ---------------------------------------------------------------------------
# base 해석 재현 스크립트 (scripts/ 는 건드리지 않음 — 이 파일 안에서만 사용)
#
# finish-task.sh scope-guard(807-891행)가 구현해야 하는 사양을 그대로
# 인코딩한 재현본. 실제 diff/위반판정은 실제 task-scope-guard.sh 로 검증한다.
# ---------------------------------------------------------------------------
_RESOLVE_SCOPE_BASE_SH = r"""#!/bin/bash
# 사용법: resolve_scope_base.sh <task_id> <scope_proj_dir> <events_dir>
# stdout: 확정된 SCOPE_BASE sha (성공 시)
# exit 0 = base 확정 + ancestor 검증 통과
# exit 1 = fail-closed (base 미확정 또는 ancestor 아님)
set -euo pipefail

TASK_ID="$1"
SCOPE_PROJ_DIR="$2"
EVENTS_DIR="$3"

MAIN_BRANCH=$(git -C "$SCOPE_PROJ_DIR" rev-parse --verify main >/dev/null 2>&1 && echo main || echo master)

SCOPE_BASE=""
BASE_SOURCE=""

# 1순위: worktree-base.json (authoritative)
WT_BASE_FILE="$EVENTS_DIR/${TASK_ID}.worktree-base.json"
if [ -f "$WT_BASE_FILE" ]; then
    SCOPE_BASE=$(WT_BASE_FILE="$WT_BASE_FILE" python3 -c "import json,os;print(json.load(open(os.environ['WT_BASE_FILE'])).get('base_sha') or '')" 2>/dev/null || echo "")
    if [ -n "$SCOPE_BASE" ]; then
        BASE_SOURCE="worktree-base.json"
    fi
fi

# 2순위: git fetch origin <main> 후 merge-base(origin/<main>, HEAD)
if [ -z "$SCOPE_BASE" ]; then
    if git -C "$SCOPE_PROJ_DIR" fetch origin "$MAIN_BRANCH" >/dev/null 2>&1; then
        SCOPE_BASE=$(git -C "$SCOPE_PROJ_DIR" merge-base "origin/${MAIN_BRANCH}" HEAD 2>/dev/null || echo "")
        if [ -n "$SCOPE_BASE" ]; then
            BASE_SOURCE="merge-base(origin/${MAIN_BRANCH})"
        fi
    fi
fi

# 3순위: fail-closed — 빈 diff로 조용히 PASS 금지
if [ -z "$SCOPE_BASE" ]; then
    echo "[SCOPE-BASE] ERROR: base 확정 불가 (worktree-base.json 없음 + origin merge-base 실패) — fail-closed" >&2
    exit 1
fi

# ancestor 가드: base 가 HEAD 의 조상이 아니면 fail-closed
if ! git -C "$SCOPE_PROJ_DIR" merge-base --is-ancestor "$SCOPE_BASE" HEAD 2>/dev/null; then
    echo "[SCOPE-BASE] ERROR: base($SCOPE_BASE) 는 HEAD 의 조상이 아님 — fail-closed" >&2
    exit 1
fi

echo "$SCOPE_BASE"
echo "[SCOPE-BASE] OK: source=$BASE_SOURCE base=$SCOPE_BASE" >&2
exit 0
"""


# ---------------------------------------------------------------------------
# 공통 헬퍼
# ---------------------------------------------------------------------------

def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
    return subprocess.run(
        ["git", "-C", str(repo), "-c", "user.email=t@t.com", "-c", "user.name=test"] + list(args),
        capture_output=True,
        text=True,
        env=GIT_ENV,
        check=check,
    )


def _rev_parse(repo: Path, ref: str = "HEAD") -> str:
    return _git(repo, "rev-parse", ref).stdout.strip()


def _write(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")


def _commit_all(repo: Path, message: str) -> str:
    _git(repo, "add", "-A")
    _git(repo, "commit", "-m", message)
    return _rev_parse(repo)


def _init_origin_and_clone(tmp_path: Path) -> tuple[Path, Path]:
    """origin(bare) + clone(main 브랜치, 초기 커밋 C0) 생성. (clone, origin) 반환."""
    origin = tmp_path / "origin.git"
    origin.mkdir()
    subprocess.run(
        ["git", "init", "--bare", "-b", "main", str(origin)],
        capture_output=True, text=True, env=GIT_ENV, check=True,
    )

    clone = tmp_path / "repo"
    subprocess.run(
        ["git", "init", "-b", "main", str(clone)],
        capture_output=True, text=True, env=GIT_ENV, check=True,
    )
    _git(clone, "remote", "add", "origin", str(origin))
    _write(clone / "README.md", "init\n")
    _commit_all(clone, "C0: init")
    _git(clone, "push", "origin", "main")
    return clone, origin


def write_worktree_base(events_dir: Path, task_id: str, base_sha: str) -> Path:
    events_dir.mkdir(parents=True, exist_ok=True)
    p = events_dir / f"{task_id}.worktree-base.json"
    _write(p, json.dumps({"task_id": task_id, "base_sha": base_sha}))
    return p


def write_snapshot(caps_dir: Path, task_id: str, paths: list[str], forbidden: list[str] | None = None) -> Path:
    caps_dir.mkdir(parents=True, exist_ok=True)
    snap = {
        "task_id": task_id,
        "captured_at": "2026-07-21T00:00:00",
        "source_sha256": "deadbeef",
        "allowed_resources": {
            "paths": paths,
            "forbidden_paths": forbidden or [],
            "commands": [],
            "merge_policy": "tiered",
            "ttl_hours": 24,
        },
    }
    p = caps_dir / f"{task_id}.json"
    _write(p, json.dumps(snap))
    return p


def resolve_scope_base(task_id: str, scope_proj_dir: Path, events_dir: Path) -> subprocess.CompletedProcess:
    """task-2818 base 해석 재현 스크립트 실행."""
    with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as f:
        f.write(_RESOLVE_SCOPE_BASE_SH)
        script_path = f.name
    try:
        return subprocess.run(
            ["bash", script_path, task_id, str(scope_proj_dir), str(events_dir)],
            capture_output=True, text=True, env=GIT_ENV,
        )
    finally:
        os.unlink(script_path)


def compute_diff(repo: Path, base: str, head: str = "HEAD") -> list[str]:
    r = _git(repo, "diff", "--name-only", f"{base}..{head}")
    return [line.strip() for line in r.stdout.splitlines() if line.strip()]


def run_scope_guard(task_id: str, diff_files: list[str], workspace: Path) -> subprocess.CompletedProcess:
    """실제 scripts/task-scope-guard.sh 를 subprocess 로 호출."""
    with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
        f.write("\n".join(diff_files) + ("\n" if diff_files else ""))
        diff_file = f.name
    try:
        env = os.environ.copy()
        env["WORKSPACE"] = str(workspace)
        return subprocess.run(
            ["bash", str(SCOPE_GUARD_SCRIPT), task_id, diff_file],
            capture_output=True, text=True, env=env,
        )
    finally:
        os.unlink(diff_file)


# ---------------------------------------------------------------------------
# T1 — 오탐0 핵심 회귀: 올바른 base(분기점) vs stale base(그 이전 커밋)
# ---------------------------------------------------------------------------

def test_stale_base_false_positive_vs_correct_base_pass(tmp_path: Path):
    task_id = "test-2818-t1"
    repo, _ = _init_origin_and_clone(tmp_path)
    workspace = tmp_path / "workspace"
    events_dir = workspace / "memory" / "events"
    caps_dir = workspace / "memory" / "capabilities"

    # C1: main 위에서 이뤄진 "무관한 머지 이력" — scope 밖 파일들 변경
    _write(repo / "server" / "main.py", "print('server')\n")
    _write(repo / "docs" / "x.md", "# doc\n")
    c1 = _commit_all(repo, "C1: unrelated merge to main (server/main.py, docs/x.md)")
    _git(repo, "push", "origin", "main")

    # task 브랜치는 C1 에서 분기 — scope 내 파일만 변경한 C2 추가
    _git(repo, "checkout", "-b", "task-branch")
    _write(repo / "scripts" / "finish-task.sh", "#!/bin/bash\necho patched\n")
    c2 = _commit_all(repo, "C2: task work, scripts only")
    head = _rev_parse(repo)
    assert head == c2

    # worktree-base.json: base_sha = C1 (정확한 분기점 = merge-base)
    write_worktree_base(events_dir, task_id, c1)
    write_snapshot(caps_dir, task_id, paths=["scripts/**"])

    # ── base 해석: worktree-base.json 이 1순위로 채택되어야 함 ──
    resolved = resolve_scope_base(task_id, repo, events_dir)
    assert resolved.returncode == 0, f"base 해석 실패: {resolved.stderr}"
    scope_base = resolved.stdout.strip()
    assert scope_base == c1, f"worktree-base.json 의 base_sha(C1)가 채택되어야 함, got {scope_base}"

    # ── 올바른 base(C1)로 diff 계산: scope 내 파일만 잡혀야 함 ──
    correct_diff = compute_diff(repo, scope_base)
    assert correct_diff == ["scripts/finish-task.sh"], (
        f"올바른 base(C1) diff 는 scripts/finish-task.sh 만 포함해야 함, got {correct_diff}"
    )
    assert "server/main.py" not in correct_diff
    assert "docs/x.md" not in correct_diff

    result_correct = run_scope_guard(task_id, correct_diff, workspace)
    assert result_correct.returncode == 0, (
        f"올바른 base 사용 시 PASS 기대, got rc={result_correct.returncode}\n"
        f"stdout={result_correct.stdout}\nstderr={result_correct.stderr}"
    )
    assert "PASS" in result_correct.stdout

    # ── 대조군: base 를 stale(C0)로 잘못 잡으면 오탐 재현 ──
    # C0 는 최초 커밋(README.md). C1 이전 이력.
    log = _git(repo, "log", "--format=%H", "--reverse")
    all_shas = [s for s in log.stdout.splitlines() if s.strip()]
    c0 = all_shas[0]
    assert c0 != c1

    stale_diff = compute_diff(repo, c0)
    assert "server/main.py" in stale_diff, (
        "stale base(C0) 사용 시 C1 에서 도입된 무관한 파일(server/main.py)이 "
        f"diff 에 섞여야 오탐 재현이 성립함, got {stale_diff}"
    )
    assert "docs/x.md" in stale_diff
    assert "scripts/finish-task.sh" in stale_diff

    result_stale = run_scope_guard(task_id, stale_diff, workspace)
    assert result_stale.returncode == 1, (
        "stale base 를 쓰면 무관한 server/main.py 가 scope 위반으로 오탐되어야 함 "
        f"(이것이 task-2818 이 고치는 버그), got rc={result_stale.returncode}"
    )
    assert any(
        "server/main.py" in line for line in result_stale.stderr.splitlines()
    ), f"stale base 오탐에 server/main.py 위반이 나타나야 함\nstderr={result_stale.stderr}"


# ---------------------------------------------------------------------------
# T2 — 진짜 위반 검출: 올바른 base 를 쓰더라도 위반은 여전히 잡힘
# ---------------------------------------------------------------------------

def test_real_violation_detected_with_correct_base(tmp_path: Path):
    task_id = "test-2818-t2"
    repo, _ = _init_origin_and_clone(tmp_path)
    workspace = tmp_path / "workspace"
    events_dir = workspace / "memory" / "events"
    caps_dir = workspace / "memory" / "capabilities"

    # C1: 무관한 머지 이력 (T1과 동일 구조)
    _write(repo / "server" / "main.py", "print('server')\n")
    c1 = _commit_all(repo, "C1: unrelated merge to main")
    _git(repo, "push", "origin", "main")

    # task 브랜치: scope 밖 파일(server/main.py)을 직접 수정하는 진짜 위반
    _git(repo, "checkout", "-b", "task-branch")
    _write(repo / "server" / "main.py", "print('server modified by task')\n")
    _commit_all(repo, "C2: task modifies server/main.py (violation)")

    write_worktree_base(events_dir, task_id, c1)
    write_snapshot(caps_dir, task_id, paths=["scripts/**"], forbidden=["server/**"])

    resolved = resolve_scope_base(task_id, repo, events_dir)
    assert resolved.returncode == 0, f"base 해석 실패: {resolved.stderr}"
    scope_base = resolved.stdout.strip()
    assert scope_base == c1

    diff_files = compute_diff(repo, scope_base)
    assert diff_files == ["server/main.py"], (
        f"올바른 base 로도 task 자체 변경인 server/main.py 만 diff 에 잡혀야 함, got {diff_files}"
    )

    result = run_scope_guard(task_id, diff_files, workspace)
    assert result.returncode == 1, (
        f"base 를 올바르게 잡아도 진짜 위반(server/main.py)은 FAIL 이어야 함\n"
        f"stdout={result.stdout}\nstderr={result.stderr}"
    )

    violation_file = events_dir / f"{task_id}.scope-violation.json"
    assert violation_file.exists(), "scope-violation.json 이 생성되어야 함"
    vdata = json.loads(violation_file.read_text(encoding="utf-8"))
    assert vdata["task_id"] == task_id
    assert any("server/main.py" in v["path"] for v in vdata["violations"])
    assert any("forbidden_paths" in v["reason"] for v in vdata["violations"])


# ---------------------------------------------------------------------------
# T3 — fail-closed: base 확정 불가 시 exit 1 (빈 diff PASS 금지)
# ---------------------------------------------------------------------------

def test_fail_closed_when_base_undeterminable(tmp_path: Path):
    task_id = "test-2818-t3"
    workspace = tmp_path / "workspace"
    events_dir = workspace / "memory" / "events"
    caps_dir = workspace / "memory" / "capabilities"
    events_dir.mkdir(parents=True, exist_ok=True)

    # git repo 는 있으나 origin remote 없음 + worktree-base.json 없음
    # → 1순위(worktree-base.json) 실패, 2순위(fetch+merge-base) 실패
    repo = tmp_path / "repo"
    repo.mkdir()
    subprocess.run(["git", "init", "-b", "main", str(repo)],
                    capture_output=True, text=True, env=GIT_ENV, check=True)
    _write(repo / "README.md", "init\n")
    _commit_all(repo, "C0: init, no origin remote")

    write_snapshot(caps_dir, task_id, paths=["scripts/**"])

    # worktree-base.json 없음을 명시적으로 확인
    assert not (events_dir / f"{task_id}.worktree-base.json").exists()
    # origin remote 없음을 명시적으로 확인 (fetch 가 실패할 수밖에 없는 조건)
    remotes = _git(repo, "remote").stdout.strip()
    assert "origin" not in remotes.splitlines()

    resolved = resolve_scope_base(task_id, repo, events_dir)
    assert resolved.returncode == 1, (
        f"base 확정 불가 상황에서 fail-closed(exit 1) 기대, got rc={resolved.returncode}\n"
        f"stdout={resolved.stdout}\nstderr={resolved.stderr}"
    )
    assert resolved.stdout.strip() == "", (
        f"fail-closed 시 base sha 를 stdout 으로 내보내면 안 됨, got {resolved.stdout!r}"
    )
    assert "fail-closed" in resolved.stderr.lower() or "확정 불가" in resolved.stderr

    # ── "빈 diff 로 조용히 PASS" 되지 않음을 명시적으로 검증 ──
    # 만약(버그 있는 구코드처럼) base 해석 실패를 무시하고 빈 diff 로 넘어갔다면
    # task-scope-guard.sh 는 checked_count=0 으로 무조건 PASS 해버린다.
    # 이것이 바로 fail-open 버그이며, task-2818 fail-closed 수정은 이 지점(빈 diff
    # 로 스코프 게이트에 도달하는 것 자체)을 rc=1 로 막아 세우는 것이다.
    would_be_empty_diff_result = run_scope_guard(task_id, [], workspace)
    assert would_be_empty_diff_result.returncode == 0, (
        "대조: 빈 diff 를 그대로 scope-guard 에 넘기면 (위반 파일이 없으므로) PASS 되어버린다 — "
        "이것이 fail-open 위험이며, base 해석이 exit 1 로 그 이전에 막아야 하는 이유다."
    )
    # 그러나 실제 base 해석 단계(resolve_scope_base)는 exit 1 이므로,
    # 정상적인 finish-task.sh 파이프라인이라면 위 empty-diff PASS 경로에
    # 아예 도달하지 못하고 여기서 이미 중단되어야 한다.
    assert resolved.returncode != 0, (
        "base 해석이 실패(rc!=0)해야 empty-diff PASS 경로 진입이 차단됨을 재확인"
    )


# ---------------------------------------------------------------------------
# T4 — ancestor 가드: base 가 HEAD 의 조상이 아니면 fail-closed
# ---------------------------------------------------------------------------

def test_ancestor_guard_rejects_non_ancestor_base(tmp_path: Path):
    task_id = "test-2818-t4"
    repo, _ = _init_origin_and_clone(tmp_path)
    workspace = tmp_path / "workspace"
    events_dir = workspace / "memory" / "events"
    caps_dir = workspace / "memory" / "capabilities"

    c0 = _rev_parse(repo)

    # task-branch: C0 에서 분기, scope 내 파일 커밋 C2
    _git(repo, "checkout", "-b", "task-branch")
    _write(repo / "scripts" / "finish-task.sh", "#!/bin/bash\necho patched\n")
    _commit_all(repo, "C2: task work on task-branch")
    head = _rev_parse(repo)

    # main 브랜치는 별도로 독립 커밋 C1' 추가 (task-branch 의 조상이 아님)
    _git(repo, "checkout", "main")
    _write(repo / "server" / "main.py", "print('unrelated main-only change')\n")
    c1_prime = _commit_all(repo, "C1': main-only commit, NOT an ancestor of task-branch")

    # task-branch 로 복귀 (HEAD = C2)
    _git(repo, "checkout", "task-branch")
    assert _rev_parse(repo) == head

    # sanity: C1' 은 실제로 task-branch(HEAD) 의 조상이 아님
    is_ancestor = subprocess.run(
        ["git", "-C", str(repo), "merge-base", "--is-ancestor", c1_prime, "HEAD"],
        capture_output=True, text=True, env=GIT_ENV,
    )
    assert is_ancestor.returncode != 0, "테스트 전제 실패: C1' 이 이미 HEAD 의 조상임"

    # worktree-base.json 에 조상이 아닌 SHA(C1')를 잘못 기록
    write_worktree_base(events_dir, task_id, c1_prime)
    write_snapshot(caps_dir, task_id, paths=["scripts/**"])

    resolved = resolve_scope_base(task_id, repo, events_dir)
    assert resolved.returncode == 1, (
        f"base 가 HEAD 의 조상이 아니면 ancestor 가드로 fail-closed(exit 1) 되어야 함, "
        f"got rc={resolved.returncode}\nstdout={resolved.stdout}\nstderr={resolved.stderr}"
    )
    assert resolved.stdout.strip() == "", (
        "ancestor 가드 실패 시 base sha 를 stdout 으로 내보내면 안 됨"
    )
    assert "조상" in resolved.stderr or "ancestor" in resolved.stderr.lower()

    # 대조: C0(진짜 조상)를 base 로 쓰면 ancestor 가드 통과 + 정상 diff
    write_worktree_base(events_dir, task_id, c0)
    resolved_ok = resolve_scope_base(task_id, repo, events_dir)
    assert resolved_ok.returncode == 0
    assert resolved_ok.stdout.strip() == c0
