#!/usr/bin/env python3
"""
post_merge_probe.py - 머지 후 5분 health probe (task-2367 P1)

사용법:
    python3 scripts/post_merge_probe.py --task-id task-XXXX --merge-sha <sha> [--project-path <path>] [--delay 300]

동작:
    1. delay 초 대기 (기본 300초 = 5분)
    2. project_path에서 build/test 재실행
    3. 결과를 audit log에 기록
    4. FAIL 시 auto_revert.py 트리거
"""

import argparse
import json
import logging
import os
import subprocess
import time
from datetime import datetime, timezone, timedelta
from pathlib import Path

KST = timezone(timedelta(hours=9))
WORKSPACE = Path(os.environ.get("WORKSPACE_ROOT", "/home/jay/workspace"))
AUDIT_LOG = WORKSPACE / "memory" / "audit" / "auto-merge.log"
PROBE_MARK_DIR = WORKSPACE / "memory" / "events"

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("post_merge_probe")

# Phase B: 변경 영역별 테스트 스코프 매핑
SCOPE_MAP = {
    "extension/": ["extension/__tests__/"],
    "server/": ["server/tests/"],
    "skills/satori-cardnews/": ["tests/skills/satori/"],
    "skills/hybrid-image/": ["tests/skills/hybrid/"],
    "scripts/": ["tests/scripts/"],
    "dashboard/": ["dashboard/tests/"],
    "src/": ["src/__tests__/"],
}
SMOKE_TEST_PATHS = ["tests/smoke/"]  # 매치 0건일 때 fallback


def _append_audit(record: dict) -> None:
    """audit log에 append-only JSONL로 기록 (fcntl.flock 사용)."""
    import fcntl
    AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
    AUDIT_LOG.touch(exist_ok=True)
    # sequence 계산: 기존 라인 수 + 1
    with open(AUDIT_LOG, "r", encoding="utf-8") as f:
        seq = sum(1 for _ in f) + 1
    record.setdefault("timestamp", datetime.now(KST).isoformat())
    record["sequence"] = seq
    with open(AUDIT_LOG, "a", encoding="utf-8") as f:
        fcntl.flock(f.fileno(), fcntl.LOCK_EX)
        try:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")
        finally:
            fcntl.flock(f.fileno(), fcntl.LOCK_UN)


def _run_build(project_path: Path) -> tuple[bool, str]:
    """프로젝트 빌드 실행. 성공 시 (True, output), 실패 시 (False, output)."""
    package_json = project_path / "package.json"
    if package_json.exists():
        try:
            r = subprocess.run(
                ["npm", "run", "build"],
                cwd=project_path,
                capture_output=True,
                text=True,
                timeout=300,
            )
            return (r.returncode == 0, (r.stdout + r.stderr)[-2000:])
        except Exception as e:
            return (False, f"build error: {e}")
    # python project — py_compile main files
    try:
        py_files = list(project_path.glob("*.py"))[:5]
        if not py_files:
            return (True, "no build target — skipped")
        r = subprocess.run(
            ["python3", "-m", "py_compile"] + [str(f) for f in py_files],
            capture_output=True,
            text=True,
            timeout=60,
        )
        return (r.returncode == 0, (r.stdout + r.stderr)[-2000:])
    except Exception as e:
        return (False, f"compile error: {e}")


def _changed_paths(project_path: Path, merge_sha: str) -> list[str]:
    """git diff <merge_sha>~1..<merge_sha> --name-only — failures return []"""
    if not merge_sha:
        return []
    try:
        r = subprocess.run(
            ["git", "diff", f"{merge_sha}~1..{merge_sha}", "--name-only"],
            cwd=project_path,
            capture_output=True,
            text=True,
            timeout=30,
        )
        if r.returncode != 0:
            return []
        return [line.strip() for line in r.stdout.splitlines() if line.strip()]
    except Exception:
        return []


def _resolve_test_scope(changed_paths: list[str]) -> tuple[list[str], str]:
    """changed_paths에서 테스트 스코프를 결정한다.

    Returns:
        (test_paths, mode) where mode is "scoped" or "smoke" (full sweep 금지)
    """
    matched: set[str] = set()
    for path in changed_paths:
        for prefix, test_dirs in SCOPE_MAP.items():
            if path.startswith(prefix):
                for d in test_dirs:
                    matched.add(d)
    if not matched:
        return (SMOKE_TEST_PATHS, "smoke")
    return (sorted(matched), "scoped")


def _run_tests_scoped(project_path: Path, test_paths: list[str]) -> tuple[bool, str]:
    """스코프 기반 테스트 실행.

    test_paths가 비어 있으면 부작용 없이 skip — full sweep 절대 금지.
    test_paths가 명시되면 존재하는 디렉토리만 pytest 인자로 전달하며,
    `--rootdir`로 conftest 탐색 범위를 project_path로 한정하고
    `-p no:cacheprovider`로 캐시 부작용을 차단한다.
    모든 test_paths가 존재하지 않으면 (True, "no scoped test target — skipped").
    """
    if not test_paths:
        # 명시적 smoke/scoped 경로가 없으면 절대 full sweep으로 fallback하지 않음
        return (True, "skipped")

    existing_paths: list[str] = []
    for tp in test_paths:
        full_path = project_path / tp
        if full_path.is_dir():
            existing_paths.append(tp)
        else:
            logger.info(f"scoped test path 없음 — skip: {tp}")

    if not existing_paths:
        return (True, "no scoped test target — skipped")

    try:
        r = subprocess.run(
            [
                "pytest",
                "--tb=short",
                "-q",
                "--rootdir",
                str(project_path),
                "-p",
                "no:cacheprovider",
            ]
            + existing_paths,
            cwd=project_path,
            capture_output=True,
            text=True,
            timeout=600,
        )
        return (r.returncode == 0, (r.stdout + r.stderr)[-2000:])
    except Exception as e:
        return (False, f"pytest error: {e}")


def run_probe(task_id: str, merge_sha: str, project_path: Path, delay: int = 300) -> dict:
    """probe 실행 후 결과 반환."""
    PROBE_MARK_DIR.mkdir(parents=True, exist_ok=True)
    mark_file = PROBE_MARK_DIR / f"{task_id}.probe-done"
    if mark_file.exists():
        logger.info(f"probe 이미 실행됨: {task_id}")
        return {"status": "already_run", "task_id": task_id}

    if delay > 0:
        logger.info(f"probe delay {delay}s 시작: {task_id}")
        time.sleep(delay)

    # Phase B: scope 결정 — full sweep 절대 금지. mode는 "scoped" 또는 "smoke" 둘 중 하나.
    changed = _changed_paths(project_path, merge_sha)
    if not changed:
        # merge_sha 없거나 diff 실패 → smoke로 강제 (full sweep 금지)
        scope_test_paths, scope_mode = (SMOKE_TEST_PATHS, "smoke")
    else:
        scope_test_paths, scope_mode = _resolve_test_scope(changed)

    build_ok, build_out = _run_build(project_path)
    test_ok, test_out = _run_tests_scoped(project_path, scope_test_paths)

    overall = build_ok and test_ok
    record = {
        "task_id": task_id,
        "merge_sha": merge_sha,
        "tier": "post-probe",
        "outcome": "probe_pass" if overall else "probe_fail",
        "merger": "post_merge_probe.py",
        "qc_result": "pass" if overall else "fail",
        "scope_guard": "skipped",
        "diff_files": [],
        "diff_loc": 0,
        "probe": {
            "build_ok": build_ok,
            "test_ok": test_ok,
            "build_out_tail": build_out[-500:],
            "test_out_tail": test_out[-500:],
        },
        "scope": {
            "changed_paths": changed,
            "test_paths": scope_test_paths,
            "mode": scope_mode,
        },
    }

    _append_audit(record)
    mark_file.write_text(json.dumps(record, ensure_ascii=False, indent=2))

    if not overall:
        # PR 책임 영역의 fail → auto_revert 호출
        # (baseline pre-flight는 별도 P1 task에서 정식화됨 — 본 task scope 외)
        logger.error(f"probe FAIL: {task_id} — auto_revert 트리거")
        try:
            subprocess.Popen(
                ["python3", str(WORKSPACE / "scripts" / "auto_revert.py"),
                 "--task-id", task_id,
                 "--merge-sha", merge_sha,
                 "--project-path", str(project_path),
                 "--reason", f"post_merge_probe FAIL: build={build_ok} test={test_ok}"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        except Exception as e:
            logger.error(f"auto_revert 호출 실패: {e}")

    return record


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--task-id", required=True)
    parser.add_argument("--merge-sha", default="")
    parser.add_argument("--project-path", default="")
    parser.add_argument("--delay", type=int, default=300)
    args = parser.parse_args()

    project_path = Path(args.project_path) if args.project_path else WORKSPACE
    result = run_probe(args.task_id, args.merge_sha, project_path, args.delay)
    print(json.dumps(result, ensure_ascii=False, indent=2))
