#!/usr/bin/env python3
"""
kis_trader/backtest/param_search_pool.py — 파라서치 ProcessPool 공통 안전장치
============================================================================
- PR_SET_PDEATHSIG + PPID 감시: 부모 종료·터미널 끊김 후에도 워커(PPID=1)가 CPU를 잡지 않게 함
- managed_process_pool: Ctrl+C / SIGTERM 시 cancel + terminate + kill 순 정리
- ParamSearchRunLock: 동일 전략 파라서치 중복 실행 방지
- ParamSearchSharedPayload: 캔들·유니버스 1회 파일 → 워커 init 1회 로드 (task마다 pickle 방지)
- param_search_max_workers: CPU 80%(고정) + 가용 RAM × PARAM_SEARCH_MEM_FRAC(기본 80%) 중 min
"""
from __future__ import annotations

import atexit
import os
import pickle
import signal
import sys
import tempfile
import time
from concurrent.futures import (
    FIRST_COMPLETED,
    Future,
    ProcessPoolExecutor,
    as_completed,
    wait,
)
from contextlib import contextmanager
from itertools import product
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar

from kis_trader.utils.env import get_env_float, get_env_int

# 워커 프로세스 전용 — fork/spawn 직후 worker_init·shared init 에서 기록
_EXPECTED_PPID: int = 0
_WORKER_SHARED: Optional[Dict[str, Any]] = None

T = TypeVar("T")


def worker_init() -> None:
    """ProcessPoolExecutor initializer — 워커 생성 직후 한 번 호출."""
    global _EXPECTED_PPID
    _EXPECTED_PPID = os.getppid()
    try:
        if sys.platform.startswith("linux"):
            import ctypes

            PR_SET_PDEATHSIG = 1
            libc = ctypes.CDLL("libc.so.6", use_errno=True)
            libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0)
    except Exception:
        pass
    try:
        signal.signal(signal.SIGINT, signal.SIG_IGN)
    except Exception:
        pass


def worker_shared_get() -> Dict[str, Any]:
    """워커: ParamSearchSharedPayload 로 로드된 캔들·유니버스 등."""
    return _WORKER_SHARED if isinstance(_WORKER_SHARED, dict) else {}


def _worker_init_from_shared_path(payload_path: str) -> None:
    """워커 1회: 디스크 pickle → _WORKER_SHARED (task 인자로 대용량 dict 전달 금지)."""
    global _WORKER_SHARED
    worker_init()
    try:
        with open(payload_path, "rb") as f:
            _WORKER_SHARED = pickle.load(f)
    except Exception:
        _WORKER_SHARED = {}


def linux_mem_available_bytes() -> int:
    """Linux MemAvailable (없으면 MemFree)."""
    try:
        mem: Dict[str, int] = {}
        with open("/proc/meminfo", "r", encoding="utf-8") as f:
            for line in f:
                parts = line.split()
                if len(parts) >= 2 and parts[0].rstrip(":") in (
                    "MemAvailable", "MemFree", "SwapFree",
                ):
                    mem[parts[0].rstrip(":")] = int(parts[1]) * 1024
        if "MemAvailable" in mem:
            return mem["MemAvailable"]
        if "MemFree" in mem:
            return mem["MemFree"]
    except OSError:
        pass
    return 2 * 1024 ** 3


def estimate_pickle_bytes(obj: Any) -> int:
    """대용량 payload 예상 크기 (pickle 직렬화 길이)."""
    try:
        return len(pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL))
    except Exception:
        return 0


def downsample_combos_uniform(
    combos: List[Any],
    max_combos: int,
) -> Tuple[List[Any], int]:
    """그리드 조합이 많을 때 균등 간격 샘플링 (tail/momentum fast 공통)."""
    cap = int(max_combos)
    total = len(combos)
    if cap <= 0 or total <= cap:
        return combos, 0
    if cap == 1:
        return [combos[0]], total - 1
    last = total - 1
    picked: List[Any] = []
    for i in range(cap):
        idx = int(round(i * last / (cap - 1)))
        picked.append(combos[idx])
    return picked, max(0, total - len(picked))


def resolve_param_search_max_combos(
    mode: str,
    *,
    strategy_env_prefix: str,
    default_fast: int,
    default_other: int = 0,
    max_combos_override: Optional[int] = None,
) -> int:
    """
    fast 모드 기본: 균등 샘플 상한.
    우선순위: CLI override → {PREFIX}_FAST_MAX_COMBOS → PARAM_SEARCH_FAST_MAX_COMBOS.
    """
    if max_combos_override is not None:
        return max(0, int(max_combos_override))
    m = (mode or "fast").strip().lower()
    if m == "fast":
        specific = get_env_int(f"{strategy_env_prefix}_FAST_MAX_COMBOS", 0)
        if specific > 0:
            return specific
        return get_env_int("PARAM_SEARCH_FAST_MAX_COMBOS", default_fast)
    cap = get_env_int(f"{strategy_env_prefix}_PARAM_SEARCH_MAX_COMBOS", default_other)
    return max(0, cap)


def cap_combos_uniform(
    dict_combos: List[Any],
    mode: str,
    *,
    strategy_env_prefix: str,
    default_fast: int,
    default_other: int = 0,
    max_combos_override: Optional[int] = None,
) -> Tuple[List[Any], int, int, int]:
    """그리드 전체 → (균등 샘플 리스트, 그리드 크기, 적용 cap, 제외 개수)."""
    total_grid = len(dict_combos)
    cap = resolve_param_search_max_combos(
        mode,
        strategy_env_prefix=strategy_env_prefix,
        default_fast=default_fast,
        default_other=default_other,
        max_combos_override=max_combos_override,
    )
    dropped = 0
    sampled = dict_combos
    if cap > 0 and total_grid > cap:
        sampled, dropped = downsample_combos_uniform(dict_combos, cap)
    return sampled, total_grid, cap, dropped


def grid_total_combinations(axes: List[List[Any]]) -> int:
    """축별 값 개수의 곱 — 데카르트곱을 **펼치지 않고** 전체 조합 수만 계산.

    (list(product(...)) 로 수천만 개를 RAM 에 펼치다 OOM 나는 것을 막기 위함)
    """
    total = 1
    for vals in axes:
        n = len(vals)
        if n <= 0:
            return 0
        total *= n
    return total


def _decode_combo_by_index(axes: List[List[Any]], idx: int) -> Tuple[Any, ...]:
    """혼합진법 디코딩: 정수 idx → 각 축의 값 (마지막 축이 최하위 자리).

    itertools.product 와 동일한 순서의 idx→조합 매핑이라, '전체 리스트를 안 만들고'
    원하는 자리(idx)의 조합만 바로 꺼낼 수 있다. 초대형 그리드 균등샘플 핵심.
    """
    out: List[Any] = [None] * len(axes)
    for ax in range(len(axes) - 1, -1, -1):
        n = len(axes[ax])
        out[ax] = axes[ax][idx % n]
        idx //= n
    return tuple(out)


def _uniform_indices(total: int, count: int) -> List[int]:
    """0..total-1 에서 균등 간격 정수 인덱스 count개 (stage1 균등샘플과 동일한 간격)."""
    if total <= 0 or count <= 0:
        return []
    if count == 1:
        return [0]
    if count >= total:
        return list(range(total))
    last = total - 1
    seen: set = set()
    out: List[int] = []
    for i in range(count):
        idx = int(round(i * last / (count - 1)))
        if idx not in seen:
            seen.add(idx)
            out.append(idx)
    return out


def cap_combos_uniform_lazy(
    keys: List[str],
    axes: List[List[Any]],
    mode: str,
    *,
    strategy_env_prefix: str,
    default_fast: int,
    default_other: int = 0,
    max_combos_override: Optional[int] = None,
    valid_fn: Optional[Callable[[Dict[str, Any]], bool]] = None,
    max_materialize: Optional[int] = None,
) -> Tuple[List[Dict[str, Any]], int, int, int]:
    """데카르트곱을 통째로 펼치지 않고 (cap 균등샘플 / 전수) dict 조합 생성.

    - 전체 곱이 PARAM_SEARCH_MAX_MATERIALIZE(기본 200만) **이하**면 기존 경로 그대로
      (materialize → 유효성 필터 → 균등 cap). 기존 동작·결과 100% 동일.
    - 그보다 **크면** 인덱스 균등샘플: 균등 간격 인덱스를 혼합진법으로 디코딩해
      그 조합만 생성한다. 수천만 개를 펼치지 않으므로 OOM 없음 + 균등 커버리지 유지.

    반환 형식은 cap_combos_uniform 과 동일: (dict_combos, total_grid, cap, dropped)
    """
    cap = resolve_param_search_max_combos(
        mode,
        strategy_env_prefix=strategy_env_prefix,
        default_fast=default_fast,
        default_other=default_other,
        max_combos_override=max_combos_override,
    )
    if max_materialize is None:
        max_materialize = get_env_int("PARAM_SEARCH_MAX_MATERIALIZE", 2_000_000)
    raw_total = grid_total_combinations(axes)

    # ── 작은 그리드: 기존 경로 그대로 (동작·결과 동일) ──
    if raw_total <= max(1, max_materialize):
        dict_combos = [dict(zip(keys, c)) for c in product(*axes)]
        if valid_fn is not None:
            dict_combos = [c for c in dict_combos if valid_fn(c)]
        total_grid = len(dict_combos)
        dropped = 0
        if cap > 0 and total_grid > cap:
            dict_combos, dropped = downsample_combos_uniform(dict_combos, cap)
        return dict_combos, total_grid, cap, dropped

    # ── 초대형 그리드: 펼치지 않고 인덱스 균등샘플 ──
    target = cap if cap > 0 else max_materialize
    # 유효성 필터로 일부 빠질 수 있어 넉넉히 뽑은 뒤(oversample) 통과분을 다시 균등 cap.
    oversample_mult = max(1, get_env_int("PARAM_SEARCH_OVERSAMPLE_MULT", 4))
    n_idx = min(raw_total, max(target, target * oversample_mult))
    picked: List[Dict[str, Any]] = []
    for idx in _uniform_indices(raw_total, n_idx):
        c = dict(zip(keys, _decode_combo_by_index(axes, idx)))
        if valid_fn is None or valid_fn(c):
            picked.append(c)
    if cap > 0 and len(picked) > cap:
        picked, _ = downsample_combos_uniform(picked, cap)
    dropped = max(0, raw_total - len(picked))
    return picked, raw_total, cap, dropped


def param_search_workers_by_memory(payload_bytes: int) -> int:
    """
    가용 RAM × PARAM_SEARCH_MEM_FRAC(기본 0.8) 안에서 워커 수 추정.
    부모 1×payload + 워커당 ~(payload + WORKER_EXTRA) 보수적 가정.
    """
    avail = linux_mem_available_bytes()
    frac = get_env_float("PARAM_SEARCH_MEM_FRAC", 0.8)
    budget = max(0, int(avail * frac))
    parent_extra = get_env_int("PARAM_SEARCH_PARENT_EXTRA_MB", 512) * 1024 ** 2
    worker_extra = get_env_int("PARAM_SEARCH_WORKER_EXTRA_MB", 384) * 1024 ** 2
    fallback = get_env_int("PARAM_SEARCH_MB_PER_WORKER", 768) * 1024 ** 2

    parent_need = (max(0, payload_bytes) + parent_extra) if payload_bytes > 0 else parent_extra + fallback
    per_worker = (max(0, payload_bytes) + worker_extra) if payload_bytes > 0 else fallback

    remain = budget - parent_need
    if remain <= 0:
        return 1
    return max(1, remain // max(per_worker, 1))


def param_search_max_workers(payload_bytes: int = 0) -> int:
    """CPU 코어 × PARAM_SEARCH_CPU_FRAC(기본 0.8=80% 고정) vs 메모리 예산 — min."""
    n_cpu = os.cpu_count() or 4
    cpu_frac = get_env_float("PARAM_SEARCH_CPU_FRAC", 0.8)
    cpu_workers = max(1, int(n_cpu * cpu_frac))
    cap = get_env_int("PARAM_SEARCH_MAX_WORKERS", 0)
    if cap > 0:
        cpu_workers = min(cpu_workers, cap)

    mem_workers = param_search_workers_by_memory(payload_bytes)
    return max(1, min(cpu_workers, mem_workers))


def param_search_worker_budget_line(payload_bytes: int) -> str:
    """run_search 로그용 — 가용 메모리·워커 산출 근거."""
    avail_gb = linux_mem_available_bytes() / (1024 ** 3)
    frac = get_env_float("PARAM_SEARCH_MEM_FRAC", 0.8)
    cpu_w = max(1, int((os.cpu_count() or 4) * get_env_float("PARAM_SEARCH_CPU_FRAC", 0.8)))
    mem_w = param_search_workers_by_memory(payload_bytes)
    workers = param_search_max_workers(payload_bytes)
    payload_mb = payload_bytes / (1024 ** 2) if payload_bytes > 0 else 0.0
    return (
        f"💾 메모리: 가용 {avail_gb:.1f}GB × {frac * 100:.0f}% 예산 | "
        f"payload ~{payload_mb:.0f}MB | CPU워커≤{cpu_w} RAM워커≤{mem_w} → 실제 {workers}"
    )


def param_search_chunk_size(total: int, max_workers: int) -> int:
    """
    청크 크기 — 조합 수가 적어도 워커 수 이상으로 나눠 CPU 포화.
    (fast 240조합 × 청크 50 → 5청크만 → 5코어만 100% 사용되는 문제 방지)
    """
    if total <= 0:
        return 50
    chunk_max = get_env_int("PARAM_SEARCH_CHUNK_MAX", 300)
    mult = max(1, get_env_int("PARAM_SEARCH_CHUNK_MULTIPLIER", 4))
    target_chunks = min(total, max(max_workers, max_workers * mult))
    return max(1, min(chunk_max, -(-total // target_chunks)))


def param_search_chunk_plan(
    total: int,
    payload_bytes: int = 0,
) -> Tuple[int, int, int]:
    """(max_workers, chunk_size, num_chunks) — run_search 공통."""
    workers = param_search_max_workers(payload_bytes)
    size = param_search_chunk_size(total, workers)
    n_chunks = max(1, -(-total // size))
    return workers, size, n_chunks


class ParamSearchProgressETA:
    """멀티프로세스 파도식 청크 완료에 맞춘 ETA 추정기.

    워커 N개가 동시에 청크를 처리하면 완료가 파도(배치) 단위로 몰린다.
    청크 간격 기반 ETA는 0↔파도통째로 요동하므로, 워밍업 1파도 제외 누적 평균 사용.
    """

    def __init__(self, total_chunks: int, max_workers: int) -> None:
        self._total_chunks = max(1, total_chunks)
        self._warmup_chunks = min(max(1, max_workers), self._total_chunks)
        self._warmup_end_elapsed: Optional[float] = None

    def remaining_sec(self, processed: int, elapsed_so_far: float) -> float:
        """남은 예상 초 — 워밍업 파도 이후 누적 평균 × 남은 청크."""
        processed = max(1, processed)
        remaining_chunks = self._total_chunks - processed
        if remaining_chunks <= 0:
            return 0.0
        if processed == self._warmup_chunks:
            self._warmup_end_elapsed = elapsed_so_far
        if self._warmup_end_elapsed is not None and processed > self._warmup_chunks:
            steady_elapsed = elapsed_so_far - self._warmup_end_elapsed
            steady_done = processed - self._warmup_chunks
            per_chunk = steady_elapsed / max(1, steady_done)
        else:
            per_chunk = elapsed_so_far / processed
        return per_chunk * remaining_chunks

    @staticmethod
    def format_sec(sec: float) -> str:
        """초 → 'N분 N초' / 'N시간 N분 N초'."""
        eta_m, eta_s = divmod(max(0, int(sec)), 60)
        eta_h, eta_m = divmod(eta_m, 60)
        if eta_h > 0:
            return f"{eta_h}시간 {eta_m}분 {eta_s}초"
        if eta_m > 0:
            return f"{eta_m}분 {eta_s}초"
        return f"{eta_s}초"

    @staticmethod
    def format_elapsed(elapsed_so_far: float) -> str:
        """경과 초 → 'N분 N초'."""
        elapsed_m, elapsed_s = divmod(max(0, int(elapsed_so_far)), 60)
        return f"{elapsed_m}분 {elapsed_s}초"

    @staticmethod
    def render_bar(frac: float, width: int = 20) -> str:
        """진행 바: 채움 ■ / 빈칸 □ (시각적 진행 표시용)."""
        frac = max(0.0, min(1.0, frac))
        filled = int(round(frac * width))
        return "■" * filled + "□" * (width - filled)


def param_search_max_inflight(max_workers: int) -> int:
    """동시 pending Future 상한 (큐에 대용량 인자 적재 방지)."""
    extra = get_env_int("PARAM_SEARCH_INFLIGHT_EXTRA", 1)
    cap = get_env_int("PARAM_SEARCH_MAX_INFLIGHT", 0)
    if cap > 0:
        return max(1, cap)
    return max(1, max_workers + max(0, extra))


class ParamSearchSharedPayload:
    """
    부모: 캔들·유니버스 등 1회 pickle 파일.
    워커: init 시 1회 로드 → evaluate 에서 worker_shared_get() 사용.
    """

    def __init__(self, data: Dict[str, Any]) -> None:
        self.data = dict(data)
        self.path: str = ""
        self._prepared = False

    def estimate_bytes(self) -> int:
        return estimate_pickle_bytes(self.data)

    def prepare(self) -> str:
        if self._prepared and self.path and os.path.isfile(self.path):
            return self.path
        fd, path = tempfile.mkstemp(prefix="kis_param_search_", suffix=".pkl")
        os.close(fd)
        with open(path, "wb") as f:
            pickle.dump(self.data, f, protocol=pickle.HIGHEST_PROTOCOL)
        self.path = path
        self._prepared = True
        return self.path

    def cleanup(self) -> None:
        if self.path and os.path.isfile(self.path):
            try:
                os.remove(self.path)
            except OSError:
                pass
        self.path = ""
        self._prepared = False


def make_process_pool(
    max_workers: int,
    *,
    shared_payload_path: Optional[str] = None,
) -> ProcessPoolExecutor:
    """ProcessPoolExecutor 생성 (max_tasks_per_child 로 장시간 워커 누수 완화).

    shared_payload_path: spawn 시 pickle 가능한 모듈 함수 + initargs 로 전달 (nested closure 금지).
    """
    max_tasks = get_env_int("PARAM_SEARCH_MAX_TASKS_PER_CHILD", 50)
    if shared_payload_path:
        init_fn = _worker_init_from_shared_path
        init_args: Tuple[Any, ...] = (shared_payload_path,)
    else:
        init_fn = worker_init
        init_args = ()
    kwargs: dict = {
        "max_workers": max_workers,
        "initializer": init_fn,
        "initargs": init_args,
    }
    if max_tasks > 0:
        kwargs["max_tasks_per_child"] = max_tasks
    return ProcessPoolExecutor(**kwargs)


def shutdown_executor_hard(
    executor: Optional[ProcessPoolExecutor],
    *,
    wait_sec: Optional[float] = None,
) -> None:
    """Executor 워커: cancel → shutdown(wait=False) → SIGTERM → SIGKILL."""
    if executor is None:
        return
    if wait_sec is None:
        wait_sec = get_env_float("PARAM_SEARCH_SHUTDOWN_WAIT_SEC", 8.0)

    try:
        executor.shutdown(wait=False, cancel_futures=True)
    except TypeError:
        executor.shutdown(wait=False)
    except Exception:
        pass

    procs = getattr(executor, "_processes", None)
    if not procs:
        return

    deadline = time.time() + wait_sec
    for proc in list(procs.values()):
        try:
            if proc.is_alive():
                proc.terminate()
        except Exception:
            pass

    while time.time() < deadline:
        if not any(p.is_alive() for p in procs.values()):
            break
        time.sleep(0.2)

    for proc in procs.values():
        try:
            if proc.is_alive():
                proc.kill()
        except Exception:
            pass


@contextmanager
def managed_process_pool(
    max_workers: int,
    *,
    shared_payload: Optional[ParamSearchSharedPayload] = None,
) -> Iterator[ProcessPoolExecutor]:
    """정상 종료는 wait=True, KeyboardInterrupt 등은 hard shutdown."""
    payload_path = shared_payload.prepare() if shared_payload else None
    executor = make_process_pool(max_workers, shared_payload_path=payload_path)
    try:
        yield executor
    except (KeyboardInterrupt, SystemExit):
        shutdown_executor_hard(executor)
        raise
    except BaseException:
        shutdown_executor_hard(executor)
        raise
    else:
        try:
            executor.shutdown(wait=True, cancel_futures=False)
        except TypeError:
            executor.shutdown(wait=True)
        except Exception:
            shutdown_executor_hard(executor)
    finally:
        if shared_payload is not None:
            shared_payload.cleanup()


def iter_pool_chunk_results(
    executor: ProcessPoolExecutor,
    chunks: List[Any],
    submit_fn: Callable[[Any], Future],
    *,
    max_workers: int = 1,
    max_inflight: Optional[int] = None,
    heartbeat_sec: Optional[float] = None,
    on_heartbeat: Optional[Callable[[int, int, float, int], None]] = None,
) -> Iterator[Any]:
    """
    청크별 Future — pending 수를 max_inflight 로 제한 (메모리·큐 폭주 방지).
    submit_fn(chunk) → Future (대용량 dict 는 ParamSearchSharedPayload 사용).

    완료 청크가 안 나오는 '대기 구간'마다(첫 청크 워밍업 포함) 하트비트를 찍어
    화면이 멈춘 것처럼 보이지 않게 한다. (사람이 진행을 눈으로 확인하고 안심하도록)
      - on_heartbeat(done_count, total, elapsed_sec, running) 콜백을 주면 그걸 호출
        (호출자가 진행 바·경과·마지막 ETA 를 같은 줄에 다시 그림 — 권장).
      - 콜백이 없으면 내장 하트비트(진행 바 + 완료 수 + 경과)를 직접 출력.
    터미널이면 ~1초마다, 파일(nohup)이면 heartbeat_sec(기본 10초)마다 갱신.
    """
    if not chunks:
        return
    inflight = max_inflight if max_inflight is not None else param_search_max_inflight(max_workers)
    if heartbeat_sec is None:
        heartbeat_sec = get_env_float("PARAM_SEARCH_HEARTBEAT_SEC", 10.0)
    use_cr = sys.stdout.isatty()
    # 터미널은 자주(1초) 갱신해 진행 바가 살아있게, 파일은 너무 잦은 줄 방지로 길게.
    beat_iv = 1.0 if use_cr else max(1.0, heartbeat_sec)
    total = len(chunks)
    pending: Dict[Future, Any] = {}
    chunk_iter = iter(chunks)

    def _fill() -> None:
        while len(pending) < inflight:
            try:
                ch = next(chunk_iter)
            except StopIteration:
                break
            pending[submit_fn(ch)] = ch

    _fill()
    start = time.time()
    done_count = 0
    while pending:
        # 완료된 청크가 생길 때까지 beat_iv 단위로 끊어 대기 → 그 사이 하트비트 출력.
        done, _ = wait(list(pending.keys()), timeout=beat_iv,
                       return_when=FIRST_COMPLETED)
        if not done:
            if heartbeat_sec <= 0:
                continue
            el = time.time() - start
            running = len(pending)
            if on_heartbeat is not None:
                on_heartbeat(done_count, total, el, running)
            else:
                bar = ParamSearchProgressETA.render_bar(done_count / max(1, total))
                if done_count == 0:
                    label = f"🔥 워밍업·첫 청크 진행 중(워커 {running})"
                else:
                    label = f"⚙️  처리 중(진행 {running})"
                msg = (f"{label} {bar} {done_count}/{total} | "
                       f"경과 {ParamSearchProgressETA.format_elapsed(el)}")
                if use_cr:
                    sys.stdout.write(f"\r{msg}   ")
                    sys.stdout.flush()
                else:
                    print(msg, flush=True)
            continue
        for fut in done:
            pending.pop(fut, None)
            done_count += 1
            yield fut.result()
        _fill()


def _is_pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False
    except PermissionError:
        return True
    else:
        return True


class ParamSearchRunLock:
    """동일 이름 파라서치 중복 실행 방지 (PID 락 파일)."""

    def __init__(self, name: str) -> None:
        self.name = name
        lock_dir = os.path.join(os.path.expanduser("~"), ".kis_bot_locks")
        os.makedirs(lock_dir, exist_ok=True)
        self.path = os.path.join(lock_dir, f"{name}.lock")
        self._acquired = False

    def acquire(self) -> bool:
        if os.path.isfile(self.path):
            old_pid = 0
            try:
                with open(self.path, "r", encoding="utf-8") as f:
                    old_pid = int(f.read().strip().split()[0])
            except (ValueError, OSError):
                old_pid = 0
            if _is_pid_alive(old_pid):
                return False
            try:
                os.remove(self.path)
            except OSError:
                pass

        try:
            fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                f.write(f"{os.getpid()}\n")
            self._acquired = True
            atexit.register(self.release)
            return True
        except FileExistsError:
            return False

    def release(self) -> None:
        if not self._acquired:
            return
        try:
            if os.path.isfile(self.path):
                with open(self.path, "r", encoding="utf-8") as f:
                    content = f.read().strip()
                if content.startswith(str(os.getpid())):
                    os.remove(self.path)
        except OSError:
            pass
        self._acquired = False

    def __enter__(self) -> "ParamSearchRunLock":
        if not self.acquire():
            raise RuntimeError(
                f"이미 실행 중인 {self.name} 파라서치가 있습니다.\n"
                f"  ps -ef | grep {self.name}\n"
                f"  pkill -f '{self.name}'  또는  rm {self.path}"
            )
        return self

    def __exit__(self, *_args) -> None:
        self.release()


def try_acquire_run_lock(name: str) -> Optional[ParamSearchRunLock]:
    """락 획득 실패 시 None (main 에서 메시지 출력용)."""
    lock = ParamSearchRunLock(name)
    if lock.acquire():
        return lock
    return None


def assert_parent_alive() -> None:
    """
    워커 청크 루프에서 호출.
    부모가 죽어 init(PPID=1)에 붙었거나 예상 부모와 다르면 즉시 종료.
    """
    ppid = os.getppid()
    if ppid == 1:
        os._exit(0)
    if _EXPECTED_PPID > 1 and ppid != _EXPECTED_PPID:
        os._exit(0)
