# -*- coding: utf-8 -*-
"""task-2729+8 P0-B real wake 결선 — regression (회귀 + mock).

대상 모듈:
  * dispatch.anu_pickup_wake_launcher (W1, launch_wake / LaunchRecord / DECISION_*)
  * dispatch.anu_pickup_driver        (W2, process_one/scan_once launcher_fn 결선)

회장 verbatim 12 검증 매핑 — 각 테스트 함수 docstring/주석에 검증번호(#N) 표기.

절대 제약:
- 실제 cokacdir/subprocess 실행 0 — subprocess_runner / launcher_fn 전부 mock 주입.
- raw ANU key 노출 0 — fake key "ANUKEY_FAKE" 가 직렬화/파일 내용에 절대 미포함 확인.
- canonical 경로 미사용 — 전부 tmp_path isolated root/ledger/audit.
- 구현 모듈(launcher/driver) 수정 0 — 본 파일은 read-only 회귀 검증.
"""
from __future__ import annotations

import json
import os
import sys
import types
from pathlib import Path

import pytest

# regression/conftest.py 가 worktree root 를 sys.path[0] 에 보장하지만,
# 단독 실행/순서 변동 대비로 한 번 더 보강한다.
_ROOT = Path(__file__).resolve().parents[2]
if str(_ROOT) not in sys.path:
    sys.path.insert(0, str(_ROOT))

# tests/dispatch (테스트용 빈 패키지) 가 실제 dispatch 패키지를 가리는 것을 방지:
# 실제 dispatch 패키지를 파일 위치로 직접 로드해 sys.modules 에 고정 후 서브모듈 import.
import importlib.util as _ilu  # noqa: E402

_real_init = _ROOT / "dispatch" / "__init__.py"
_cached = sys.modules.get("dispatch")
if _cached is None or (getattr(_cached, "__file__", "") or "") != str(_real_init):
    for _k in [k for k in list(sys.modules) if k == "dispatch" or k.startswith("dispatch.")]:
        del sys.modules[_k]
    _spec = _ilu.spec_from_file_location(
        "dispatch", _real_init, submodule_search_locations=[str(_ROOT / "dispatch")]
    )
    assert _spec is not None and _spec.loader is not None
    _pkg = _ilu.module_from_spec(_spec)
    sys.modules["dispatch"] = _pkg
    _spec.loader.exec_module(_pkg)

from dispatch import anu_pickup_driver as drv  # noqa: E402
from dispatch import anu_pickup_wake_launcher as lch  # noqa: E402


# ─────────────────────────────────────────────────────────────────────────────
# 공통 상수/헬퍼
# ─────────────────────────────────────────────────────────────────────────────
# ★ fake key — 실제 ANU key literal 아님. raw key 노출 0 검사의 prober 로만 사용.
FAKE_KEY = "ANUKEY_FAKE"
VALID_ARGV = ["cokacdir", "--cron", "x", "--key", FAKE_KEY]


def _ledger_path(tmp_path: Path) -> str:
    return str(tmp_path / "ledger" / "wake_launch_ledger.jsonl")


def _audit_path(tmp_path: Path) -> str:
    return str(tmp_path / "audit" / "wake_launch_audit.jsonl")


def make_runner_mock(returncode: int = 0):
    """subprocess_runner mock. 호출 인자(argv)를 calls 에 기록. 실제 실행 0.

    반환: (runner_fn, calls_list) 튜플. 호출부는 별도 calls_list 변수를 참조한다
    (함수 객체에 .calls 속성을 부착하지 않아 pyright reportFunctionMemberAccess 회피).
    """
    calls: list = []

    def _runner(argv):
        calls.append(argv)
        return returncode

    return _runner, calls


# ── driver 헬퍼 (2721 회귀 패턴 차용) ───────────────────────────────────────
VALID_PAYLOAD = {
    "task_id": "task-999",
    "completion_signal": "EXECUTOR_RESULT_WRITTEN",
    "collector_envelope": {"task_id": "task-999", "schedule_id": "sch-1"},
    "report_path": "r.md",
    "sha256": "abc",
}


def _make_dirs(tmp_path: Path) -> Path:
    (tmp_path / "memory" / "events").mkdir(parents=True, exist_ok=True)
    (tmp_path / "memory" / "state").mkdir(parents=True, exist_ok=True)
    return tmp_path


def _events_dir(root: Path) -> Path:
    return root / "memory" / "events"


def _write_result(root: Path, name: str = "task-999.result.json", payload=None) -> Path:
    p = _events_dir(root) / name
    if payload is None:
        payload = VALID_PAYLOAD
    p.write_text(json.dumps(payload), encoding="utf-8")
    return p


import time as _time  # noqa: E402


def _age(path, seconds: float = 10.0) -> None:
    """result 파일 mtime 을 과거로 → readiness aged 게이트 통과."""
    past = _time.time() - seconds
    os.utime(str(path), (past, past))


_NO_SLEEP = lambda *a, **k: None  # noqa: E731


def make_verify_mock(verdict=drv.VERDICT_AUTHORITATIVE):
    """verify_fn mock — AUTHORITATIVE 반환. 호출 인자 calls 기록."""
    calls: list = []

    def _verify(*args, **kwargs):
        calls.append((args, kwargs))
        return types.SimpleNamespace(verdict=verdict, ok=True, classification="", reasons=[])

    return _verify, calls


def make_pickup_mock(verdict="WAKE_BUILT", argv=None, sha256="sha-1"):
    """pickup_fn mock — verdict/argv/sha256 부여한 result 반환. 호출 인자 calls 기록."""
    calls: list = []
    _argv = list(VALID_ARGV) if argv is None else argv

    def _pickup(*args, **kwargs):
        calls.append((args, kwargs))
        return types.SimpleNamespace(
            verdict=verdict,
            ok=(verdict == "WAKE_BUILT"),
            argv=_argv,
            sha256=sha256,
            task_id="task-999",
            reasons=[],
        )

    return _pickup, calls


def make_launcher_mock(decision=lch.DECISION_DRY_RUN):
    """launcher_fn mock — 호출 인자 capture + LaunchRecord 유사 객체 반환."""
    calls: list = []

    def _launcher(argv, **kwargs):
        calls.append((argv, kwargs))
        return types.SimpleNamespace(decision=decision)

    return _launcher, calls


# =============================================================================
# A. launcher 단위 (launch_wake)
# =============================================================================

# ── A1. dry_run 기본 decision-only — 실제 spawn 0 (검증 #8) ──────────────────
def test_a1_dry_run_default_decision_only_no_spawn(tmp_path):
    runner, runner_calls = make_runner_mock(0)
    rec = lch.launch_wake(
        list(VALID_ARGV),
        task_id="task-1",
        sha256="sha-1",
        # dry_run 생략 → 기본 True
        launch_ledger_path=_ledger_path(tmp_path),
        subprocess_runner=runner,
    )
    assert rec.decision == lch.DECISION_DRY_RUN
    assert rec.dry_run is True
    # ★ 검증 #8: 실제 spawn 0 — subprocess_runner 호출 0회
    assert len(runner_calls) == 0


# ── A2. dry_run production write 0 (검증 #9,#10) ──────────────────────────────
def test_a2_dry_run_production_write_zero(tmp_path):
    ledger = _ledger_path(tmp_path)
    rec = lch.launch_wake(
        list(VALID_ARGV),
        task_id="task-1",
        sha256="sha-1",
        dry_run=True,
        launch_ledger_path=ledger,
        # audit_path 미주입 → audit 파일 미생성이어야 함
    )
    assert rec.decision == lch.DECISION_DRY_RUN
    # ★ 검증 #9,#10: production ledger/audit write 0 (OWNER_TRIGGER_DRY_RUN_LEDGER_CONTAMINATION)
    assert not os.path.exists(ledger)
    # 기본 audit 경로(미주입)도 생성되지 않음
    assert not os.path.exists(_audit_path(tmp_path))


# ── A3. dry_run audit_path 주입 시 isolated 기록 + raw key 0 (검증 #7) ────────
def test_a3_dry_run_audit_path_isolated_no_raw_key(tmp_path):
    ledger = _ledger_path(tmp_path)
    audit = _audit_path(tmp_path)
    rec = lch.launch_wake(
        list(VALID_ARGV),
        task_id="task-1",
        sha256="sha-1",
        dry_run=True,
        launch_ledger_path=ledger,
        audit_path=audit,
    )
    assert rec.decision == lch.DECISION_DRY_RUN
    assert os.path.exists(audit)
    content = Path(audit).read_text(encoding="utf-8")
    lines = [ln for ln in content.splitlines() if ln.strip()]
    assert len(lines) == 1  # audit 1줄 기록
    # ★ 검증 #7: raw key 0 — fake key 가 audit 내용에 미포함
    assert FAKE_KEY not in content
    # launch_ledger 는 여전히 미생성 (dry_run production write 0)
    assert not os.path.exists(ledger)


# ── A4. argv None → FAIL_CLOSED_NO_ARGV, spawn 0 ─────────────────────────────
def test_a4_argv_none_fail_closed(tmp_path):
    runner, runner_calls = make_runner_mock(0)
    rec = lch.launch_wake(
        None,
        task_id="task-1",
        sha256="sha-1",
        dry_run=False,
        launch_ledger_path=_ledger_path(tmp_path),
        subprocess_runner=runner,
    )
    assert rec.decision == lch.DECISION_FAIL_CLOSED_NO_ARGV
    assert len(runner_calls) == 0


# ── A5. argv malformed → FAIL_CLOSED_MALFORMED ───────────────────────────────
@pytest.mark.parametrize("bad_argv", [
    ["cokacdir", "--key", FAKE_KEY],            # --cron 없음
    ["cokacdir", "--cron", "x"],                # --key 없음
    ["cokacdir", "--cron", "x", "--key", ""],   # --key 값 빈 문자열
])
def test_a5_argv_malformed_fail_closed(tmp_path, bad_argv):
    runner, runner_calls = make_runner_mock(0)
    rec = lch.launch_wake(
        bad_argv,
        task_id="task-1",
        sha256="sha-1",
        dry_run=False,
        launch_ledger_path=_ledger_path(tmp_path),
        subprocess_runner=runner,
    )
    assert rec.decision == lch.DECISION_FAIL_CLOSED_MALFORMED
    assert len(runner_calls) == 0


# ── A6. non-ANU key → FAIL_CLOSED_NON_ANU_KEY (검증자 False/예외 동일) ────────
def test_a6_non_anu_key_fail_closed(tmp_path):
    runner, runner_calls = make_runner_mock(0)
    # (a) verifier False
    rec = lch.launch_wake(
        list(VALID_ARGV),
        task_id="task-1",
        sha256="sha-1",
        dry_run=False,
        launch_ledger_path=_ledger_path(tmp_path),
        anu_key_verifier=lambda k: False,
        subprocess_runner=runner,
    )
    assert rec.decision == lch.DECISION_FAIL_CLOSED_NON_ANU_KEY
    assert len(runner_calls) == 0

    # (b) verifier 예외 → 동일 decision (fail-closed)
    def _boom(k):
        raise RuntimeError("verifier boom")

    rec2 = lch.launch_wake(
        list(VALID_ARGV),
        task_id="task-1",
        sha256="sha-1",
        dry_run=False,
        launch_ledger_path=_ledger_path(tmp_path),
        anu_key_verifier=_boom,
        subprocess_runner=runner,
    )
    assert rec2.decision == lch.DECISION_FAIL_CLOSED_NON_ANU_KEY
    assert len(runner_calls) == 0


# ── A7. real launch (dry_run=False) + mock runner — argv 정확 + raw key 0 (검증 #7) ─
def test_a7_real_launch_mock_runner(tmp_path):
    ledger = _ledger_path(tmp_path)
    runner, runner_calls = make_runner_mock(0)
    rec = lch.launch_wake(
        list(VALID_ARGV),
        task_id="task-7",
        sha256="sha-7",
        dry_run=False,
        launch_ledger_path=ledger,
        subprocess_runner=runner,
    )
    assert rec.decision == lch.DECISION_LAUNCHED
    assert rec.returncode == 0
    # mock 정확히 1회 호출 + 호출 인자가 정확히 argv
    assert len(runner_calls) == 1
    assert runner_calls[0] == VALID_ARGV
    # launch_ledger 에 WAKE_LAUNCHED + task_id/sha256 기록
    assert os.path.exists(ledger)
    content = Path(ledger).read_text(encoding="utf-8")
    lines = [json.loads(ln) for ln in content.splitlines() if ln.strip()]
    assert len(lines) == 1
    entry = lines[0]
    assert entry.get("event") == lch.EVENT_WAKE_LAUNCHED
    assert entry.get("task_id") == "task-7"
    assert entry.get("sha256") == "sha-7"
    # ★ 검증 #7: raw key 0 — ledger 내용에 fake key 미포함
    assert FAKE_KEY not in content


# ── A8. dedupe — 동일 (task_id, sha256) 재호출 시 SKIP_DEDUPE, 추가 spawn 0 (검증 #6) ─
def test_a8_dedupe_no_duplicate_wake(tmp_path):
    ledger = _ledger_path(tmp_path)
    runner, runner_calls = make_runner_mock(0)
    # 1차 real launch → LAUNCHED (ledger 기록)
    rec1 = lch.launch_wake(
        list(VALID_ARGV),
        task_id="task-dup",
        sha256="sha-dup",
        dry_run=False,
        launch_ledger_path=ledger,
        subprocess_runner=runner,
    )
    assert rec1.decision == lch.DECISION_LAUNCHED
    assert len(runner_calls) == 1

    # 2차 동일 (task_id, sha256) 재호출 → SKIP_DEDUPE
    rec2 = lch.launch_wake(
        list(VALID_ARGV),
        task_id="task-dup",
        sha256="sha-dup",
        dry_run=False,
        launch_ledger_path=ledger,
        subprocess_runner=runner,
    )
    assert rec2.decision == lch.DECISION_SKIP_DEDUPE
    # ★ 검증 #6: 중복 wake 0 — subprocess mock 추가 호출 0 (여전히 1회)
    assert len(runner_calls) == 1


# ── A9. ledger error fail-closed (검증 #5) ───────────────────────────────────
def test_a9_ledger_error_fail_closed(tmp_path):
    ledger = _ledger_path(tmp_path)
    os.makedirs(os.path.dirname(ledger), exist_ok=True)
    # 손상된 JSON 줄이 든 ledger 파일
    Path(ledger).write_text("{not valid json\n", encoding="utf-8")

    runner, runner_calls = make_runner_mock(0)
    rec = lch.launch_wake(
        list(VALID_ARGV),
        task_id="task-1",
        sha256="sha-1",
        dry_run=False,
        launch_ledger_path=ledger,
        subprocess_runner=runner,
    )
    assert rec.decision == lch.DECISION_FAIL_CLOSED_LEDGER_ERROR
    # ★ 검증 #5: ledger/marker failure → wake 0 (subprocess 호출 0)
    assert len(runner_calls) == 0


# ── A10. LaunchRecord.to_json raw key 0 (검증 #7) ────────────────────────────
def test_a10_launch_record_to_json_no_raw_key():
    rec = lch.LaunchRecord(
        ts="2026-06-06T00:00:00Z",
        task_id="task-1",
        sha256="sha-1",
        decision=lch.DECISION_LAUNCHED,
        dry_run=False,
        returncode=0,
        argv_len=len(VALID_ARGV),
    )
    d = rec.to_json()
    # argv/key 키 부재, argv_len(정수)만 존재
    assert "argv" not in d
    assert "key" not in d
    assert isinstance(d.get("argv_len"), int)
    # ★ 검증 #7: json.dumps 결과에 fake key 미포함
    dumped = json.dumps(d, ensure_ascii=False)
    assert FAKE_KEY not in dumped


# =============================================================================
# B. driver 결선 (process_one/scan_once)
# =============================================================================

# ── B11. launcher_fn=None 기본 동작 보존 (검증 #12) ──────────────────────────
def test_b11_launcher_none_preserves_legacy(tmp_path):
    root = _make_dirs(tmp_path)
    pickup, _pickup_calls = make_pickup_mock(verdict="WAKE_BUILT")
    verify, _verify_calls = make_verify_mock()

    p = _write_result(root)
    _age(p)

    rec = drv.process_one(
        str(p), root=root, pickup_fn=pickup, verify_fn=verify,
        sleep_fn=_NO_SLEEP,
        # launcher_fn 미지정 → 기본 None
    )
    # ★ 검증 #12: 기존 decision-only 동작 보존 — WAKE_BUILT + fire_cron_id None
    assert rec.verdict == drv.VERDICT_WAKE_BUILT
    assert rec.fire_cron_id is None


# ── B12. mock launcher decision-only 호출 인자 기록 (검증 #1,#10) ─────────────
def test_b12_mock_launcher_decision_only(tmp_path):
    root = _make_dirs(tmp_path)
    pickup, _pickup_calls = make_pickup_mock(verdict="WAKE_BUILT", argv=list(VALID_ARGV), sha256="sha-zz")
    verify, _verify_calls = make_verify_mock()
    launcher, launcher_calls = make_launcher_mock(decision=lch.DECISION_DRY_RUN)

    p = _write_result(root)
    _age(p)

    rec = drv.process_one(
        str(p), root=root, pickup_fn=pickup, verify_fn=verify,
        launcher_fn=launcher, sleep_fn=_NO_SLEEP,
    )
    # ★ 검증 #1: WAKE_BUILT 경로에서 launcher 정확히 1회 호출
    assert len(launcher_calls) == 1
    call_argv, call_kwargs = launcher_calls[0]
    # 첫 인자 = res.argv
    assert call_argv == VALID_ARGV
    # kwargs task_id/sha256 일치
    assert call_kwargs.get("task_id") == "task-999"
    assert call_kwargs.get("sha256") == "sha-zz"
    # DriverRecord.fire_cron_id == decision 라벨
    assert rec.verdict == drv.VERDICT_WAKE_BUILT
    assert rec.fire_cron_id == lch.DECISION_DRY_RUN
    # ★ 검증 #10: 실제 실행 0 — record 직렬화에 raw key 미포함
    assert FAKE_KEY not in json.dumps(rec.to_json(), ensure_ascii=False)


# ── B13. WAKE_BUILT 에서만 launcher 호출 (검증 #2,#3,#4,#5) ───────────────────
@pytest.mark.parametrize("pv", ["SKIP_TERMINAL", "SKIP_DEDUPE", "QUARANTINE"])
def test_b13_launcher_only_on_wake_built(tmp_path, pv):
    root = _make_dirs(tmp_path)
    pickup, _pickup_calls = make_pickup_mock(verdict=pv)
    verify, _verify_calls = make_verify_mock()
    launcher, launcher_calls = make_launcher_mock()

    p = _write_result(root)
    _age(p)

    drv.process_one(
        str(p), root=root, pickup_fn=pickup, verify_fn=verify,
        launcher_fn=launcher, sleep_fn=_NO_SLEEP,
    )
    # ★ 검증 #2,#3,#4,#5: 비-WAKE_BUILT verdict → launcher 호출 0
    assert len(launcher_calls) == 0


# ── B14. terminal marker no-op (검증 #3) ─────────────────────────────────────
def test_b14_terminal_marker_noop(tmp_path):
    root = _make_dirs(tmp_path)
    pickup, pickup_calls = make_pickup_mock(verdict="WAKE_BUILT")
    verify, _verify_calls = make_verify_mock()
    launcher, launcher_calls = make_launcher_mock()

    p = _write_result(root)
    _age(p)
    # 동일 task_id 의 done marker → 조건6 에서 PICKUP_SKIP 조기 반환
    marker = _events_dir(root) / "task-999.pickup.done"
    marker.write_text("done", encoding="utf-8")

    rec = drv.process_one(
        str(p), root=root, pickup_fn=pickup, verify_fn=verify,
        launcher_fn=launcher, sleep_fn=_NO_SLEEP,
    )
    # ★ 검증 #3: terminal marker 존재 → PICKUP_SKIP, pickup_fn/launcher 미호출
    assert rec.verdict == drv.VERDICT_PICKUP_SKIP
    assert len(pickup_calls) == 0
    assert len(launcher_calls) == 0


# ── B15. launcher 예외 fail-safe (크래시 0) ──────────────────────────────────
def test_b15_launcher_exception_failsafe(tmp_path):
    root = _make_dirs(tmp_path)
    pickup, _pickup_calls = make_pickup_mock(verdict="WAKE_BUILT")
    verify, _verify_calls = make_verify_mock()

    def _boom(argv, **kwargs):
        raise RuntimeError("launcher boom")

    p = _write_result(root)
    _age(p)

    rec = drv.process_one(
        str(p), root=root, pickup_fn=pickup, verify_fn=verify,
        launcher_fn=_boom, sleep_fn=_NO_SLEEP,
    )
    # ★ 크래시 0 — WAKE_BUILT 유지 + error 에 "launcher 예외" 포함 + fire_cron_id None
    assert rec.verdict == drv.VERDICT_WAKE_BUILT
    assert rec.error is not None and "launcher 예외" in rec.error
    assert rec.fire_cron_id is None
