"""Heartbeat primitives shared by the live liveness probe and the Phase 7 audit.

`claude-worker` appends `- PROGRESS: <stage> <ISO-8601-UTC>` lines to its audit
sidecar so a silent hang is visible while the run is still recoverable. The
Phase 7 validator has always parsed those lines post-hoc; `worker_liveness`
reads them mid-run. Both must agree on what "stale" means, so the line shape and
the cadence budget live here rather than in either consumer.
"""
from __future__ import annotations

import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path

# claude-worker audit 사이드카의 heartbeat 라인 (claude-worker.md "Heartbeat").
HEARTBEAT_LINE_RE = re.compile(
    r"^-[ \t]*PROGRESS:[ \t]*(?P<stage>\S+)[ \t]+(?P<ts>\S+)[ \t]*$", re.MULTILINE
)

# 한 단계가 cadence 보다 길어질 때 워커가 append 하는 진행 라인의 접두사
# (claude-worker.md "Heartbeat"). 뒤에 붙는 stage 이름은 원래 단계 그대로다.
IN_STAGE_PREFIX = "in-stage:"

# 계약상 cadence 는 5분. append 직전 측정한 시각과 실제 쓰기 사이 지연을 흡수하는
# 고정 grace 60초를 더한다.
HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60

# 단일 도구 호출이 구간 전체를 차지해 워커가 하트비트를 끼워 넣을 수 없는 단계.
# 한 번의 Write 가 진행되는 동안에는 `in-stage:` 라인도 append 할 수 없으므로
# 5분 예산으로는 살아 있는 워커가 stalled 로 판정된다 (실측 dev-10172:
# report-writer 가 74KB data.json 한 개를 쓰는 11분 동안 무하트비트).
SINGLE_WRITE_STAGES = frozenset({
    "data-json-write-start",
    "render-start",
    "write-result-start",
})
SINGLE_WRITE_MAX_GAP_SECONDS = 20 * 60 + 60

# report-writer 가 도구 호출 없이 모든 입력을 정독(required-reading-complete)하거나
# 종합(synthesis-start)하는 구간. 그 사이에는 `in-stage:` 라인조차 append 할 수 없어
# 5분 예산으로는 살아 있는 워커가 stalled 로 판정된다 (실측: synthesis-start gap 41건
# 중 24%가 5분+60s 초과, 최대 14.9분). 단일 Write 만큼 길지는 않으므로 15분 예산.
SYNTHESIS_STAGES = frozenset({
    "required-reading-complete",
    "synthesis-start",
})
SYNTHESIS_MAX_GAP_SECONDS = 15 * 60 + 60


def max_gap_seconds_after(stage: str) -> int:
    """*stage* 를 알린 뒤 다음 하트비트까지 허용되는 최대 공백(초).

    간격이 재는 것은 직전에 선언된 단계의 작업 시간이므로, 예산은 언제나
    구간을 *여는* 단계에서 고른다. `in-stage:<X>` 는 아직 X 안에 있다는 뜻이라
    그 라인이 여는 구간도 여전히 X 의 일부다 — 접두사를 떼고 X 의 예산을 쓴다."""
    opener = stage.removeprefix(IN_STAGE_PREFIX)
    if opener in SINGLE_WRITE_STAGES:
        return SINGLE_WRITE_MAX_GAP_SECONDS
    if opener in SYNTHESIS_STAGES:
        return SYNTHESIS_MAX_GAP_SECONDS
    return HEARTBEAT_MAX_GAP_SECONDS


@dataclass(frozen=True)
class Heartbeat:
    """사이드카에 기록된 하나의 heartbeat."""
    stage: str
    at: datetime


def parse_timestamp(raw: str) -> datetime | None:
    """A heartbeat's ISO-8601 timestamp as an aware datetime, or None."""
    try:
        parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
    except ValueError:
        return None
    return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)


def latest_heartbeat(sidecar: Path) -> Heartbeat | None:
    """The newest parseable heartbeat in *sidecar*, or None when it has none.

    Takes the maximum rather than the last line: a regressing timestamp is the
    Phase 7 validator's finding to report, and treating it as "stale" here would
    kill a worker that is in fact still writing.
    """
    try:
        text = sidecar.read_text(encoding="utf-8")
    except OSError:
        return None
    beats = [
        Heartbeat(match.group("stage"), parsed)
        for match in HEARTBEAT_LINE_RE.finditer(text)
        if (parsed := parse_timestamp(match.group("ts"))) is not None
    ]
    return max(beats, key=lambda beat: beat.at) if beats else None
