# -*- coding: utf-8 -*-
"""task-2724 TERMINAL_STATE_CALLBACK_CONTRACT — 23 acceptance regression tests (TC-20~23: DEFAULT_ANU_KEYS empty StopIteration 방어).

설계 회장 확정 계약 검증:
 1.  NORMAL_SUCCESS(.done 존재) → failure envelope 생성 0
 2.  external-dirty-blocker.json + .done 없음 → FINISH_BLOCKED_EXTERNAL_DIRTY
 3.  scope-violation marker → FINISH_BLOCKED_SCOPE_VIOLATION
 4.  git gate blocker → FINISH_BLOCKED_GIT_GATE
 5.  qc-result FAIL → QC_FAILED
 6.  marker 전무 → UNKNOWN_FINISH_FAILURE (fail-closed)
 7.  같은 task_id+attempt_id+terminal_state 2회 호출 → envelope/registration 각 1회만
 8.  emit 2회(lock 존재) → emit 1회만
 9.  bot self-key → argv=None → 미등록
10.  terminal_state_callback.py 소스에 c119085addb0f8b7 리터럴 0건
11.  callback owner 검증: owner_key != ANU → 미등록(callback_registered=false)
12.  argv 존재 → registration marker 생성
13.  sendfile-only 금지: failure + 미등록 시 registration marker NOT_REGISTERED 기록
14.  emitter 예외 발생해도 exit 0 (fail-open)
15.  기존 source JSON schema 파괴 0: 입력 파일 수정 없음
"""
from __future__ import annotations

import importlib
import importlib.util
import json
import os
import sys
import types
from pathlib import Path
from typing import Optional, List
from unittest import mock

import pytest

# ── sys.path: worktree root 우선 ──────────────────────────────────────────────
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
    sys.path.insert(0, str(_ROOT))


# ── 모듈 로더 헬퍼 ────────────────────────────────────────────────────────────
def _load_module(modname: str, relpath: str):
    """worktree-local 모듈 강제 로드 (캐시 교체)."""
    fpath = _ROOT / relpath
    spec = importlib.util.spec_from_file_location(modname, fpath)
    assert spec is not None and spec.loader is not None
    mod = importlib.util.module_from_spec(spec)
    sys.modules[modname] = mod
    spec.loader.exec_module(mod)
    return mod


def _ensure_dispatch():
    """dispatch 패키지가 sys.modules에 없으면 worktree-local 로드."""
    if "dispatch.normal_fallback_callback_helper" not in sys.modules:
        _load_module(
            "dispatch.callback_owner_enforcer",
            "dispatch/callback_owner_enforcer.py",
        )
        _load_module(
            "dispatch.normal_fallback_callback_helper",
            "dispatch/normal_fallback_callback_helper.py",
        )


def _load_tsc():
    """terminal_state_callback 모듈을 새로 로드 (테스트 격리)."""
    _ensure_dispatch()
    # 캐시에서 제거하여 fresh 로드 보장
    for key in list(sys.modules.keys()):
        if "terminal_state_callback" in key:
            del sys.modules[key]
    return _load_module(
        "scripts.harness.v36.terminal_state_callback",
        "scripts/harness/v36/terminal_state_callback.py",
    )


# ── LaunchDecision stub 팩토리 ────────────────────────────────────────────────
def _make_decision(argv: Optional[List[str]], verdict: str = "PASS"):
    """launch_callback 반환값 stub."""
    dec = mock.MagicMock()
    dec.argv = argv
    dec.verdict = verdict
    return dec


# ── 공통 픽스처 헬퍼 ──────────────────────────────────────────────────────────
def _setup_events(tmp_path: Path, task_id: str = "task-2724"):
    events_dir = tmp_path / "events"
    events_dir.mkdir(parents=True, exist_ok=True)
    done_file = str(events_dir / f"{task_id}.done")
    return str(events_dir), done_file


# ─────────────────────────────────────────────────────────────────────────────
# TC-1: NORMAL_SUCCESS (.done 존재) → failure envelope 생성 0
# ─────────────────────────────────────────────────────────────────────────────
def test_normal_success_no_failure_envelope(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    # .done 파일 생성
    Path(done_file).touch()

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(None)):
        tsc.emit(
            task_id="task-2724",
            events_dir=events_dir,
            workspace=str(tmp_path),
            done_file=done_file,
        )

    events = Path(events_dir)
    envelope_path = events / "task-2724.terminal-state.json"
    assert envelope_path.exists(), "NORMAL_SUCCESS envelope 미생성"

    with open(envelope_path) as f:
        env = json.load(f)

    # success=True, terminal_state=NORMAL_SUCCESS
    assert env["terminal_state"] == tsc.NORMAL_SUCCESS
    assert env["success"] is True

    # failure 산출물 없음 (.done.blocked 등)
    failure_files = [
        f for f in events.iterdir()
        if "blocked" in f.name or "failure" in f.name
    ]
    assert not failure_files, f"failure 산출물 존재: {failure_files}"


# ─────────────────────────────────────────────────────────────────────────────
# TC-2: external-dirty-blocker.json + .done 없음 → FINISH_BLOCKED_EXTERNAL_DIRTY
# ─────────────────────────────────────────────────────────────────────────────
def test_external_dirty_blocker(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    # marker 생성
    marker = Path(events_dir) / f"{task_id}.external-dirty-blocker.json"
    marker.write_text(json.dumps({"reason": "dirty"}))

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(["cokacdir", "--cron", "x", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"])):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    with open(Path(events_dir) / f"{task_id}.terminal-state.json") as f:
        env = json.load(f)
    assert env["terminal_state"] == tsc.FINISH_BLOCKED_EXTERNAL_DIRTY
    assert env["success"] is False


# ─────────────────────────────────────────────────────────────────────────────
# TC-3: scope-violation → FINISH_BLOCKED_SCOPE_VIOLATION
# ─────────────────────────────────────────────────────────────────────────────
def test_scope_violation(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    marker = Path(events_dir) / f"{task_id}.scope-violation.json"
    marker.write_text(json.dumps({"reason": "scope"}))

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(["cokacdir", "--cron", "x", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"])):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    with open(Path(events_dir) / f"{task_id}.terminal-state.json") as f:
        env = json.load(f)
    assert env["terminal_state"] == tsc.FINISH_BLOCKED_SCOPE_VIOLATION
    assert env["success"] is False


# ─────────────────────────────────────────────────────────────────────────────
# TC-4: git gate blocker → FINISH_BLOCKED_GIT_GATE
# ─────────────────────────────────────────────────────────────────────────────
def test_git_gate_blocker(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    marker = Path(events_dir) / f"{task_id}.git-gate-blocker.json"
    marker.write_text(json.dumps({"reason": "uncommitted"}))

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(["cokacdir", "--cron", "x", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"])):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    with open(Path(events_dir) / f"{task_id}.terminal-state.json") as f:
        env = json.load(f)
    assert env["terminal_state"] == tsc.FINISH_BLOCKED_GIT_GATE
    assert env["success"] is False


# ─────────────────────────────────────────────────────────────────────────────
# TC-5: qc-result FAIL → QC_FAILED
# ─────────────────────────────────────────────────────────────────────────────
def test_qc_failed(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    qc_file = Path(events_dir) / f"{task_id}.qc-result"
    qc_file.write_text("FAIL")

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(["cokacdir", "--cron", "x", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"])):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    with open(Path(events_dir) / f"{task_id}.terminal-state.json") as f:
        env = json.load(f)
    assert env["terminal_state"] == tsc.QC_FAILED
    assert env["success"] is False


# ─────────────────────────────────────────────────────────────────────────────
# TC-6: marker 전무 → UNKNOWN_FINISH_FAILURE (fail-closed)
# ─────────────────────────────────────────────────────────────────────────────
def test_unknown_finish_failure(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(["cokacdir", "--cron", "x", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"])):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    with open(Path(events_dir) / f"{task_id}.terminal-state.json") as f:
        env = json.load(f)
    assert env["terminal_state"] == tsc.UNKNOWN_FINISH_FAILURE
    assert env["success"] is False


# ─────────────────────────────────────────────────────────────────────────────
# TC-7: 같은 dedupe_key 2회 호출 → envelope/registration 각 1회만
# ─────────────────────────────────────────────────────────────────────────────
def test_dedupe_envelope_and_registration(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"
    argv = ["cokacdir", "--cron", "x", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"]

    call_count = {"n": 0}
    def fake_launch(**kwargs):
        call_count["n"] += 1
        return _make_decision(argv)

    # subprocess.run stub: head_sha 호출(returncode=0, stdout="abc123")과 cron 등록(returncode=0) 모두 처리
    def fake_subprocess_run(cmd, **kwargs):
        result = mock.MagicMock()
        result.returncode = 0
        result.stdout = "abc123def"
        return result

    with mock.patch.object(tsc, "launch_callback", side_effect=fake_launch):
        with mock.patch.object(tsc.subprocess, "run", side_effect=fake_subprocess_run):
            # 1차 emit
            tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)
            # 2차 emit: lock이 이미 있으므로 no-op
            tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    events = Path(events_dir)
    envelope_path = events / f"{task_id}.terminal-state.json"
    reg_marker = events / f"{task_id}.terminal-callback-registered.json"

    assert envelope_path.exists()
    # launch_callback은 1번만 호출됨
    assert call_count["n"] == 1, f"launch_callback 호출 횟수 {call_count['n']} != 1"
    # registration marker도 1개
    assert reg_marker.exists()


# ─────────────────────────────────────────────────────────────────────────────
# TC-8: lock 존재(emit 중복) → emit 1회만
# ─────────────────────────────────────────────────────────────────────────────
def test_lock_prevents_double_emit(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    # lock 파일을 미리 생성
    lock_path = Path(events_dir) / f"{task_id}.terminal-emit-lock"
    lock_path.touch()

    call_count = {"n": 0}
    def fake_launch(**kwargs):
        call_count["n"] += 1
        return _make_decision(None)

    with mock.patch.object(tsc, "launch_callback", side_effect=fake_launch):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    # lock 이미 있으므로 emit 실행되지 않아야 함
    envelope_path = Path(events_dir) / f"{task_id}.terminal-state.json"
    assert not envelope_path.exists(), "lock 존재 시 envelope 생성되면 안 됨"
    assert call_count["n"] == 0, "lock 존재 시 launch_callback 호출 안 해야 함"


# ─────────────────────────────────────────────────────────────────────────────
# TC-9: bot self-key → argv=None → 미등록 확인
# ─────────────────────────────────────────────────────────────────────────────
def test_self_key_not_registered(tmp_path, monkeypatch):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    # executor self-key == owner_key → argv=None (fail-closed)
    monkeypatch.setenv("COKACDIR_KEY_SELF", "self-key-12345678")

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(None, verdict="FAIL_CLOSED")):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    with open(Path(events_dir) / f"{task_id}.terminal-state.json") as f:
        env = json.load(f)

    assert env["callback_registered"] is False

    # registration marker: NOT_REGISTERED
    reg_marker = Path(events_dir) / f"{task_id}.terminal-callback-registered.json"
    assert reg_marker.exists()
    with open(reg_marker) as f:
        rm = json.load(f)
    assert rm["status"] == "NOT_REGISTERED"
    assert rm["reason"] == "fail-closed"


# ─────────────────────────────────────────────────────────────────────────────
# TC-10: terminal_state_callback.py 소스에 ANU key 리터럴 0건
# ─────────────────────────────────────────────────────────────────────────────
def test_no_anu_key_literal_in_source():
    src_path = _ROOT / "scripts" / "harness" / "v36" / "terminal_state_callback.py"
    content = src_path.read_text(encoding="utf-8")
    # ANU key 리터럴 c119085addb0f8b7 절대 금지
    assert "c119085addb0f8b7" not in content, "ANU key 리터럴 소스에 존재함"


# ─────────────────────────────────────────────────────────────────────────────
# TC-11: callback owner 검증: owner_key != ANU → 미등록(callback_registered=false)
# ─────────────────────────────────────────────────────────────────────────────
def test_non_anu_owner_not_registered(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    # non-ANU owner → argv=None
    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(None, verdict="FAIL_CLOSED")):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    with open(Path(events_dir) / f"{task_id}.terminal-state.json") as f:
        env = json.load(f)
    assert env["callback_registered"] is False


# ─────────────────────────────────────────────────────────────────────────────
# TC-12: argv 존재 → registration marker 생성
# ─────────────────────────────────────────────────────────────────────────────
def test_argv_present_registration_marker_created(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"
    argv = ["cokacdir", "--cron", "prompt", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"]

    def fake_subprocess_run(cmd, **kwargs):
        result = mock.MagicMock()
        result.returncode = 0
        result.stdout = "abc123def"
        return result

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(argv)):
        with mock.patch.object(tsc.subprocess, "run", side_effect=fake_subprocess_run):
            tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    reg_marker = Path(events_dir) / f"{task_id}.terminal-callback-registered.json"
    assert reg_marker.exists(), "argv 존재 시 registration marker 생성되어야 함"
    with open(reg_marker) as f:
        rm = json.load(f)
    assert rm.get("status") == "REGISTERED"

    with open(Path(events_dir) / f"{task_id}.terminal-state.json") as f:
        env = json.load(f)
    assert env["callback_registered"] is True


# ─────────────────────────────────────────────────────────────────────────────
# TC-13: sendfile-only 금지 — failure + 미등록 시 NOT_REGISTERED marker 기록
# ─────────────────────────────────────────────────────────────────────────────
def test_sendfile_only_forbidden_fail_closed_marker(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    # argv=None (fail-closed)
    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(None, verdict="FAIL_CLOSED")):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    # envelope.callback_registered=false
    with open(Path(events_dir) / f"{task_id}.terminal-state.json") as f:
        env = json.load(f)
    assert env["callback_registered"] is False

    # registration marker: NOT_REGISTERED + reason=fail-closed
    reg_marker = Path(events_dir) / f"{task_id}.terminal-callback-registered.json"
    assert reg_marker.exists(), "NOT_REGISTERED marker 생성되어야 함"
    with open(reg_marker) as f:
        rm = json.load(f)
    assert rm["status"] == "NOT_REGISTERED"
    assert rm.get("reason") == "fail-closed"


# ─────────────────────────────────────────────────────────────────────────────
# TC-14: emitter 예외 발생해도 exit 0 (fail-open)
# ─────────────────────────────────────────────────────────────────────────────
def test_fail_open_exception(tmp_path):
    tsc = _load_tsc()
    # 잘못된 events_dir (쓰기 불가 경로 시뮬레이션)
    bad_events_dir = "/nonexistent/path/events"
    done_file = "/nonexistent/path/events/task-2724.done"

    # main() 호출 → 예외 흡수 → exit 0
    ret = tsc.main([
        "emit",
        "--task-id", "task-2724",
        "--events-dir", bad_events_dir,
        "--workspace", "/nonexistent",
        "--done-file", done_file,
    ])
    assert ret == 0, f"fail-open: main() 반환값이 0이어야 함, got {ret}"


# ─────────────────────────────────────────────────────────────────────────────
# TC-15: 기존 source JSON schema 파괴 0 — 입력 파일 수정 없음
# ─────────────────────────────────────────────────────────────────────────────
def test_source_files_not_modified(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    # 입력 marker 파일 생성
    ext_dirty = Path(events_dir) / f"{task_id}.external-dirty-blocker.json"
    original_content = json.dumps({"reason": "dirty", "schema": "external-dirty-blocker.v1"})
    ext_dirty.write_text(original_content, encoding="utf-8")

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(None)):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    # 파일 내용 불변 확인
    after_content = ext_dirty.read_text(encoding="utf-8")
    assert after_content == original_content, "외부 source 파일이 수정됨"


# ─────────────────────────────────────────────────────────────────────────────
# TC-16: injectable runner — cron 등록은 주입 runner 로만, 실제 cokacdir subprocess 미호출
# ─────────────────────────────────────────────────────────────────────────────
def test_injectable_runner_no_real_cokacdir(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"
    argv = ["cokacdir", "--cron", "x", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"]

    runner_calls = {"n": 0, "argv": None}
    def mock_runner(a):
        runner_calls["n"] += 1
        runner_calls["argv"] = a
        r = mock.MagicMock(); r.returncode = 0; return r

    # subprocess.run 은 git head_sha 에만 허용. cokacdir/--cron 이 실제 subprocess 로 들어오면 위반.
    cokacdir_subproc = {"n": 0}
    def spy_subprocess_run(cmd, **kwargs):
        if any(("cokacdir" in str(c)) or (c == "--cron") for c in (cmd or [])):
            cokacdir_subproc["n"] += 1
        r = mock.MagicMock(); r.returncode = 0; r.stdout = "deadbeef"; return r

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(argv)):
        with mock.patch.object(tsc.subprocess, "run", side_effect=spy_subprocess_run):
            tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file, runner=mock_runner)

    assert runner_calls["n"] == 1, "주입 runner 가 1회 호출되어야 함"
    assert runner_calls["argv"] == argv
    assert cokacdir_subproc["n"] == 0, "실제 cokacdir cron 이 subprocess 로 등록됨(회장 9-7 위반)"

    reg_marker = Path(events_dir) / f"{task_id}.terminal-callback-registered.json"
    with open(reg_marker) as f:
        rm = json.load(f)
    assert rm["status"] == "REGISTERED"


# ─────────────────────────────────────────────────────────────────────────────
# TC-17: dry_run=True → 실제 등록 backend 호출 0, marker DRY_RUN, callback_registered False
# ─────────────────────────────────────────────────────────────────────────────
def test_dry_run_no_registration_backend_call(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"
    argv = ["cokacdir", "--cron", "x", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"]

    runner_calls = {"n": 0}
    def mock_runner(a):
        runner_calls["n"] += 1
        r = mock.MagicMock(); r.returncode = 0; return r

    cokacdir_subproc = {"n": 0}
    def spy_subprocess_run(cmd, **kwargs):
        if any(("cokacdir" in str(c)) or (c == "--cron") for c in (cmd or [])):
            cokacdir_subproc["n"] += 1
        r = mock.MagicMock(); r.returncode = 0; r.stdout = "deadbeef"; return r

    with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(argv)):
        with mock.patch.object(tsc.subprocess, "run", side_effect=spy_subprocess_run):
            tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file, runner=mock_runner, dry_run=True)

    assert runner_calls["n"] == 0, "dry_run 시 주입 runner 호출 0 이어야 함"
    assert cokacdir_subproc["n"] == 0, "dry_run 시 실제 cokacdir subprocess 호출 0"

    with open(Path(events_dir) / f"{task_id}.terminal-state.json") as f:
        env = json.load(f)
    assert env["callback_registered"] is False

    reg_marker = Path(events_dir) / f"{task_id}.terminal-callback-registered.json"
    with open(reg_marker) as f:
        rm = json.load(f)
    assert rm["status"] == "DRY_RUN"
    assert rm["registered"] is False


# ─────────────────────────────────────────────────────────────────────────────
# TC-18: workspace passthrough → canonical_root 반영 (다른 workspace 주입 시 그 값 사용)
# ─────────────────────────────────────────────────────────────────────────────
def test_workspace_passthrough_to_canonical_root(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"
    custom_ws = "/tmp/custom-workspace-xyz"
    argv = ["cokacdir", "--cron", "x", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"]

    captured = {}
    def fake_launch(**kwargs):
        captured.update(kwargs)
        return _make_decision(argv)

    def spy_subprocess_run(cmd, **kwargs):
        r = mock.MagicMock(); r.returncode = 0; r.stdout = "sha"; return r

    with mock.patch.object(tsc, "launch_callback", side_effect=fake_launch):
        with mock.patch.object(tsc.subprocess, "run", side_effect=spy_subprocess_run):
            tsc.emit(task_id=task_id, events_dir=events_dir, workspace=custom_ws, done_file=done_file, dry_run=True)

    assert captured.get("canonical_root") == custom_ws, "주입 workspace 가 canonical_root 로 passthrough 안 됨"
    assert f"canonical_root={custom_ws}" in captured.get("prompt", ""), "prompt 에 canonical_root passthrough 안 됨"


# ─────────────────────────────────────────────────────────────────────────────
# TC-19: terminal_state_callback.py 소스에 /home/jay/workspace 하드코딩 0건
# ─────────────────────────────────────────────────────────────────────────────
def test_no_workspace_hardcode_in_source():
    src_path = _ROOT / "scripts" / "harness" / "v36" / "terminal_state_callback.py"
    content = src_path.read_text(encoding="utf-8")
    assert "/home/jay/workspace" not in content, "canonical_root /home/jay/workspace 하드코딩 잔존"


# ─────────────────────────────────────────────────────────────────────────────
# TC-20: DEFAULT_ANU_KEYS empty → StopIteration 0 (emit crash 없음), envelope 디스크 잔존
# ─────────────────────────────────────────────────────────────────────────────
def test_empty_anu_keys_no_stopiteration(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    # empty key set → StopIteration 없이 정상 종료해야 함
    with mock.patch.object(tsc, "DEFAULT_ANU_KEYS", frozenset()):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    env_path = Path(events_dir) / f"{task_id}.terminal-state.json"
    assert env_path.exists(), "envelope 가 디스크에 남아야 함 (crash 0)"
    with open(env_path) as f:
        env = json.load(f)
    assert env["callback_registered"] is False


# ─────────────────────────────────────────────────────────────────────────────
# TC-21: DEFAULT_ANU_KEYS empty → registration marker NOT_REGISTERED + reason NO_OWNER_KEY
# ─────────────────────────────────────────────────────────────────────────────
def test_empty_anu_keys_no_owner_key_marker(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    with mock.patch.object(tsc, "DEFAULT_ANU_KEYS", frozenset()):
        tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    reg_marker = Path(events_dir) / f"{task_id}.terminal-callback-registered.json"
    assert reg_marker.exists(), "empty key 시 NO_OWNER_KEY marker 생성되어야 함"
    with open(reg_marker) as f:
        rm = json.load(f)
    assert rm["status"] == "NOT_REGISTERED"
    assert rm["reason"] == "NO_OWNER_KEY"


# ─────────────────────────────────────────────────────────────────────────────
# TC-22: DEFAULT_ANU_KEYS empty → launch_callback 미호출 + 실제 cron(subprocess) 등록 0
# ─────────────────────────────────────────────────────────────────────────────
def test_empty_anu_keys_no_real_cron_registration(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"

    lc = mock.MagicMock()
    # subprocess.run spy: _get_head_sha 등 emit 내부 git 조회는 serializable stdout 반환.
    # 실제 cron 등록 backend(_default_callback_runner)는 owner_key None fail-closed 로 진입조차 안 함.
    cron_calls = {"n": 0}
    def sp(cmd, *a, **k):
        if cmd and cmd[0] == "cokacdir":
            cron_calls["n"] += 1  # 실제 cron 등록 backend 호출 (발생하면 안 됨)
        r = mock.MagicMock(); r.returncode = 0; r.stdout = "sha"; return r

    with mock.patch.object(tsc, "DEFAULT_ANU_KEYS", frozenset()):
        with mock.patch.object(tsc, "launch_callback", lc):
            with mock.patch.object(tsc.subprocess, "run", side_effect=sp):
                tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file)

    lc.assert_not_called()      # owner_key None → launch_callback 진입 전 fail-closed
    assert cron_calls["n"] == 0  # 실제 cron 등록 backend(cokacdir) 미호출


# ─────────────────────────────────────────────────────────────────────────────
# TC-23: DEFAULT_ANU_KEYS 정상 1원소 → 기존 registration flow 무손상 (회귀 방지)
# ─────────────────────────────────────────────────────────────────────────────
def test_nonempty_anu_keys_registration_flow_intact(tmp_path):
    tsc = _load_tsc()
    events_dir, done_file = _setup_events(tmp_path)
    task_id = "task-2724"
    argv = ["cokacdir", "--cron", "p", "--at", "+5m", "--chat", "6937032012", "--key", "anukey", "--once"]

    runner_calls = {"n": 0}
    def fake_runner(a):
        runner_calls["n"] += 1
        r = mock.MagicMock(); r.returncode = 0; return r

    # 1원소 정상 set + 주입 runner → 실제 cokacdir 0
    with mock.patch.object(tsc, "DEFAULT_ANU_KEYS", frozenset({"owner-placeholder"})):
        with mock.patch.object(tsc, "launch_callback", return_value=_make_decision(argv)):
            tsc.emit(task_id=task_id, events_dir=events_dir, workspace=str(tmp_path), done_file=done_file, runner=fake_runner)

    reg_marker = Path(events_dir) / f"{task_id}.terminal-callback-registered.json"
    with open(reg_marker) as f:
        rm = json.load(f)
    assert rm["status"] == "REGISTERED"
    assert runner_calls["n"] == 1
