"""anu_v2.idle_pr_diagnoser — OPEN PR 상태 머신 분류 (task-2556 §2 / §3 / §4).

회장 §명시 2026-05-12 KST (task-2556 12 필수 §2~§4):
  §2 task_id / head_sha / CI / Gemini evidence 상태 진단 — 각 OPEN PR별 상태 머신 분류
  §3 FIRST_GEMINI_TRIGGER_MISSING 감지 — PR createdAt + 30min 경과 + Gemini reviews 0
  §4 GEMINI_STALE_ON_HEAD 감지 — PR head SHA != Gemini review commit_id

task-2563 회장 §명시 2026-05-13 KST (OWNER_TRIGGER_ONLY_CAPABILITY hardening §1):
  - ``FIRST_TRIGGER_PENDING`` 신규 상태 추가 — PR open 직후 짧은 시간 동안 Gemini external
    trigger 대기. ``FIRST_GEMINI_TRIGGER_MISSING`` 은 polling_policy.FIRST_TIMEOUT_SECONDS
    (1800s) 경과 후에만 확정 (조기 owner_trigger 호출 위험 차단).
  - ``FIRST_TRIGGER_PENDING_WINDOW_SECONDS`` (default 300s) 짧은 grace window. 이 구간을
    벗어나면 PENDING 으로 분류되며, scheduler 는 ``dispatch_decision.owner_trigger_fast_path
    == true`` 일 때만 조기 dispatch 허용 (그 외는 SKIP).

본 모듈은 입력 PR 메타데이터를 정적으로 분석해 9 가지 상태 코드 중 하나를 반환한다.
부수효과 0 (network/disk 접근 없음). 외부에서 ``gh pr list --json ...`` 결과를 정규화해
넘기면, 본 모듈은 순수 함수처럼 상태를 분류한다.

상태 머신 (9 states):
  - ``WITHIN_GRACE_PERIOD``: PR createdAt 후 ``FIRST_TRIGGER_PENDING_WINDOW_SECONDS``
    (default 300s = "PR open 직후 짧은 시간") 이내 — 어떤 trigger 결정도 보류.
  - ``FIRST_TRIGGER_PENDING``: 짧은 grace window 경과 + ``FIRST_TIMEOUT_SECONDS`` 미경과
    + reviews 0 → Gemini external trigger 대기. fast_path=true 일 때만 조기 trigger 가능.
  - ``FIRST_GEMINI_TRIGGER_MISSING``: ``FIRST_TIMEOUT_SECONDS`` (1800s) 경과 + reviews 0
    → 누락 확정, owner trigger 필요 (회장 §3).
  - ``GEMINI_STALE_ON_HEAD``: head SHA != latest review commit_id → owner trigger 필요
  - ``GEMINI_FRESH_ON_HEAD``: head SHA == latest review commit_id → ready for merge gate
  - ``CI_FAILED``: ci.required_all_success == False → 후속 작업은 task 봇 책임
  - ``MISSING_TASK_ID``: branch 에서 task_id 추출 실패 → 격리 위반
  - ``UNKNOWN_STATE``: 위 어디에도 해당 없음 (fail-closed default)
  - ``OWNER_TRIGGER_REQUIRED``: aggregated 결과 (FIRST_GEMINI_TRIGGER_MISSING |
    GEMINI_STALE_ON_HEAD) — scheduler 가 owner trigger 호출 대상

one-way isolation: anu_v2/ 외부 import 금지.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Final, Sequence

from anu_v2.polling_policy import FIRST_TIMEOUT_SECONDS


# ─── 상태 코드 (회장 §명시 1:1) ──────────────────────────────────────────────

STATE_WITHIN_GRACE_PERIOD: Final[str] = "WITHIN_GRACE_PERIOD"
STATE_FIRST_TRIGGER_PENDING: Final[str] = "FIRST_TRIGGER_PENDING"
STATE_FIRST_GEMINI_TRIGGER_MISSING: Final[str] = "FIRST_GEMINI_TRIGGER_MISSING"
STATE_GEMINI_STALE_ON_HEAD: Final[str] = "GEMINI_STALE_ON_HEAD"
STATE_GEMINI_FRESH_ON_HEAD: Final[str] = "GEMINI_FRESH_ON_HEAD"
STATE_CI_FAILED: Final[str] = "CI_FAILED"
STATE_MISSING_TASK_ID: Final[str] = "MISSING_TASK_ID"
STATE_UNKNOWN: Final[str] = "UNKNOWN_STATE"
STATE_OWNER_TRIGGER_REQUIRED: Final[str] = "OWNER_TRIGGER_REQUIRED"

ALL_STATES: Final[frozenset[str]] = frozenset({
    STATE_WITHIN_GRACE_PERIOD,
    STATE_FIRST_TRIGGER_PENDING,
    STATE_FIRST_GEMINI_TRIGGER_MISSING,
    STATE_GEMINI_STALE_ON_HEAD,
    STATE_GEMINI_FRESH_ON_HEAD,
    STATE_CI_FAILED,
    STATE_MISSING_TASK_ID,
    STATE_UNKNOWN,
    STATE_OWNER_TRIGGER_REQUIRED,
})

# task-2563 §1 1:1: PENDING 은 default 로 owner trigger 자동 dispatch 대상이 **아님**.
# fast_path=true 가 명시된 decision JSON 이 있을 때만 scheduler 가 trigger 한다.
# 본 frozenset 에 FIRST_TRIGGER_PENDING 추가 금지 (회장 §명시 1:1, fail-closed 기본값).
OWNER_TRIGGER_INVOKING_STATES: Final[frozenset[str]] = frozenset({
    STATE_FIRST_GEMINI_TRIGGER_MISSING,
    STATE_GEMINI_STALE_ON_HEAD,
})

# 회장 § grace period — PR open 직후 짧은 시간 (default 5min) 동안은 어떤 trigger 결정도 보류.
# task-2563 §1 1:1: 기존 30min == FIRST_TIMEOUT_SECONDS 와 분리, 짧은 window 도입.
FIRST_TRIGGER_PENDING_WINDOW_SECONDS: Final[int] = 5 * 60

# 후방 호환 alias — 기존 코드는 grace period == 30min 로 부르고 있었음. 신규 의미:
# "FIRST_TIMEOUT_SECONDS 와 1:1 일치하는 PENDING → MISSING 확정 경계".
GRACE_PERIOD_SECONDS: Final[int] = FIRST_TIMEOUT_SECONDS

# task branch 패턴 (task-NNNN[+M]-devN[-suffix] 또는 task/...).
_TASK_BRANCH_RE: Final[re.Pattern[str]] = re.compile(
    r"^(?:task/)?(?P<id>task-\d+(?:\+\d+)?)"
)


# ─── 입력 dataclasses ──────────────────────────────────────────────────────


@dataclass(frozen=True)
class GeminiReviewMeta:
    """Gemini review 한 건 (PR 상 commented review)."""

    commit_id: str   # 40-char hex SHA, lower-case
    submitted_at: str  # ISO 8601 UTC

    def __post_init__(self) -> None:
        if not isinstance(self.commit_id, str) or len(self.commit_id) != 40:
            raise ValueError("commit_id must be 40-char hex SHA")
        lowered = self.commit_id.lower()
        if any(c not in "0123456789abcdef" for c in lowered):
            raise ValueError("commit_id must be 40-char hex SHA")
        if not isinstance(self.submitted_at, str) or not self.submitted_at:
            raise ValueError("submitted_at must be non-empty ISO 8601 string")


@dataclass(frozen=True)
class IdlePRSnapshot:
    """OPEN PR 한 건의 진단 입력 snapshot (gh pr view 정규화 결과).

    Attributes:
      number: PR 번호 (1+).
      head_sha: 현재 PR head 40-char hex SHA.
      head_ref: branch 이름 (e.g. "task/task-2556-dev5").
      created_at: PR createdAt ISO 8601 UTC.
      gemini_reviews: 가장 오래된 → 최신 순 정렬된 Gemini review 리스트.
      ci_required_all_success: 필수 CI 11 checks 모두 SUCCESS 여부.
      state: gh PR state (OPEN / CLOSED / MERGED).
      author_is_bot: 작성자가 bot 인지 (회장 수동 PR 식별).
    """

    number: int
    head_sha: str
    head_ref: str
    created_at: str
    gemini_reviews: tuple[GeminiReviewMeta, ...] = field(default_factory=tuple)
    ci_required_all_success: bool = False
    state: str = "OPEN"
    author_is_bot: bool = True

    def __post_init__(self) -> None:
        if not isinstance(self.number, int) or isinstance(self.number, bool) or self.number <= 0:
            raise ValueError("number must be positive int")
        if not isinstance(self.head_sha, str) or len(self.head_sha) != 40:
            raise ValueError("head_sha must be 40-char hex SHA")
        if any(c not in "0123456789abcdef" for c in self.head_sha.lower()):
            raise ValueError("head_sha must be 40-char hex SHA")
        if not isinstance(self.head_ref, str) or not self.head_ref:
            raise ValueError("head_ref must be non-empty string")
        if not isinstance(self.created_at, str) or not self.created_at:
            raise ValueError("created_at must be non-empty ISO 8601 string")


@dataclass(frozen=True)
class IdlePRDiagnosis:
    """진단 결과 — 단일 상태 + 부가 정보 + scheduler 가 사용할 task_id.

    Attributes:
      state: ``ALL_STATES`` 중 하나.
      task_id: head_ref 에서 추출된 task id (e.g. "task-2556"); 추출 실패 시 "".
      pr_number: PR 번호.
      head_sha: PR head SHA (lower-case).
      latest_gemini_commit_id: 가장 최근 gemini review commit_id (없으면 None).
      reason: 사람이 읽을 수 있는 한 줄 사유.
      elapsed_since_created_seconds: createdAt → now_iso 경과 초.
    """

    state: str
    task_id: str
    pr_number: int
    head_sha: str
    latest_gemini_commit_id: str | None
    reason: str
    elapsed_since_created_seconds: int

    def __post_init__(self) -> None:
        if self.state not in ALL_STATES:
            raise ValueError(f"state must be one of {ALL_STATES}, got {self.state!r}")

    @property
    def requires_owner_trigger(self) -> bool:
        """scheduler 가 owner trigger runner 를 호출해야 하는 상태인지."""
        return self.state in OWNER_TRIGGER_INVOKING_STATES


# ─── helpers ───────────────────────────────────────────────────────────────


def extract_task_id(head_ref: str) -> str:
    """branch 이름에서 task id 추출. 실패 시 빈 문자열.

    e.g. ``task/task-2556-dev5`` → ``task-2556``.
    e.g. ``task/task-2556+1-dev5`` → ``task-2556+1``.
    """
    if not isinstance(head_ref, str) or not head_ref:
        return ""
    m = _TASK_BRANCH_RE.match(head_ref)
    if not m:
        return ""
    return m.group("id")


def _parse_iso_utc(value: str) -> datetime:
    """ISO 8601 (Z 또는 +00:00) → datetime(UTC). 실패 시 ValueError."""
    if not isinstance(value, str) or not value:
        raise ValueError("ISO 8601 string required")
    normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
    dt = datetime.fromisoformat(normalized)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc)


def elapsed_seconds_since(created_at: str, *, now: str | None = None) -> int:
    """createdAt 이후 경과 초 (정수). now 미지정 시 ``datetime.now(UTC)``.

    음수가 반환되면 (시계 skew) caller 는 0 으로 정규화하여 사용.
    """
    created_dt = _parse_iso_utc(created_at)
    if now is not None:
        now_dt = _parse_iso_utc(now)
    else:
        now_dt = datetime.now(timezone.utc)
    delta = now_dt - created_dt
    return int(delta.total_seconds())


# ─── core diagnoser ────────────────────────────────────────────────────────


class IdlePRDiagnoser:
    """OPEN PR snapshot 을 9 상태 중 하나로 분류 (회장 §2 / §3 / §4 + task-2563 §1).

    설계:
      - 순수 함수형. 입력 ``IdlePRSnapshot`` + 선택적 ``now`` ISO 문자열.
      - 외부 부수효과 0.
      - 상태 우선순위 (위 → 아래로 평가, 첫 match 가 결과):
          1. MISSING_TASK_ID  (격리 위반 — head_ref 파싱 실패)
          2. CI_FAILED        (회장 §명시 외 영역 — task 봇 책임)
          3. WITHIN_GRACE_PERIOD (PR createdAt 후 짧은 grace window 이내; task-2563 §1)
          4. FIRST_TRIGGER_PENDING (grace window 경과 + FIRST_TIMEOUT_SECONDS 미경과 + reviews 0)
          5. FIRST_GEMINI_TRIGGER_MISSING (FIRST_TIMEOUT_SECONDS 경과 + reviews 0)
          6. GEMINI_STALE_ON_HEAD (latest review commit_id != head_sha)
          7. GEMINI_FRESH_ON_HEAD (latest review commit_id == head_sha)
          8. UNKNOWN_STATE (fail-closed default)

    회장 §3 정확 일치: PR createdAt + FIRST_TIMEOUT_SECONDS (1800s) 경과 + Gemini reviews 0
                       → FIRST_GEMINI_TRIGGER_MISSING (확정).
    task-2563 §1 1:1: PR open 직후 짧은 시간 + reviews 0 → FIRST_TRIGGER_PENDING (대기).
    회장 §4 정확 일치: head_sha != latest commit_id → GEMINI_STALE_ON_HEAD.
    """

    def __init__(
        self,
        *,
        pending_window_seconds: int = FIRST_TRIGGER_PENDING_WINDOW_SECONDS,
        first_timeout_seconds: int = FIRST_TIMEOUT_SECONDS,
    ) -> None:
        if not isinstance(pending_window_seconds, int) or pending_window_seconds < 0:
            raise ValueError("pending_window_seconds must be non-negative int")
        if not isinstance(first_timeout_seconds, int) or first_timeout_seconds < 0:
            raise ValueError("first_timeout_seconds must be non-negative int")
        if pending_window_seconds > first_timeout_seconds:
            raise ValueError(
                "pending_window_seconds must be <= first_timeout_seconds "
                "(short grace window precedes PENDING precedes MISSING confirmation)"
            )
        self._pending_window_seconds = pending_window_seconds
        self._first_timeout_seconds = first_timeout_seconds

    def diagnose(
        self,
        snapshot: Any,
        *,
        now: str | None = None,
    ) -> IdlePRDiagnosis:
        """상태 분류. 회장 §2~§4 1:1."""
        if not isinstance(snapshot, IdlePRSnapshot):
            raise TypeError("snapshot must be IdlePRSnapshot instance")

        task_id = extract_task_id(snapshot.head_ref)
        head_sha = snapshot.head_sha.lower()
        latest_commit_id: str | None = None
        if snapshot.gemini_reviews:
            latest_commit_id = snapshot.gemini_reviews[-1].commit_id.lower()

        try:
            elapsed = elapsed_seconds_since(snapshot.created_at, now=now)
        except ValueError:
            elapsed = 0
        elapsed = max(elapsed, 0)

        # 1) MISSING_TASK_ID
        if not task_id:
            return IdlePRDiagnosis(
                state=STATE_MISSING_TASK_ID,
                task_id="",
                pr_number=snapshot.number,
                head_sha=head_sha,
                latest_gemini_commit_id=latest_commit_id,
                reason=f"head_ref={snapshot.head_ref!r} does not match task branch pattern",
                elapsed_since_created_seconds=elapsed,
            )

        # 2) CI_FAILED — task 봇 책임 (scheduler 는 owner trigger 호출 안 함).
        if not snapshot.ci_required_all_success:
            return IdlePRDiagnosis(
                state=STATE_CI_FAILED,
                task_id=task_id,
                pr_number=snapshot.number,
                head_sha=head_sha,
                latest_gemini_commit_id=latest_commit_id,
                reason="ci_required_all_success is False — task bot must fix CI before owner trigger",
                elapsed_since_created_seconds=elapsed,
            )

        # 3) WITHIN_GRACE_PERIOD — createdAt + 짧은 grace window (default 5min) 이내.
        # task-2563 §1 1:1: "PR open 직후 짧은 시간" 동안은 어떤 trigger 결정도 보류.
        if elapsed < self._pending_window_seconds:
            return IdlePRDiagnosis(
                state=STATE_WITHIN_GRACE_PERIOD,
                task_id=task_id,
                pr_number=snapshot.number,
                head_sha=head_sha,
                latest_gemini_commit_id=latest_commit_id,
                reason=(
                    f"elapsed={elapsed}s < pending_window={self._pending_window_seconds}s "
                    f"— PR open 직후 짧은 시간, 어떤 trigger 결정도 보류"
                ),
                elapsed_since_created_seconds=elapsed,
            )

        # 4) FIRST_TRIGGER_PENDING — pending_window 경과 + FIRST_TIMEOUT 미경과 + reviews 0.
        # task-2563 §1 1:1: PENDING 상태는 default 로 owner trigger 자동 dispatch 대상 아님.
        # scheduler 는 ``dispatch_decision.owner_trigger_fast_path == true`` 일 때만 조기 dispatch.
        if elapsed < self._first_timeout_seconds and not snapshot.gemini_reviews:
            return IdlePRDiagnosis(
                state=STATE_FIRST_TRIGGER_PENDING,
                task_id=task_id,
                pr_number=snapshot.number,
                head_sha=head_sha,
                latest_gemini_commit_id=None,
                reason=(
                    f"pending_window={self._pending_window_seconds}s <= elapsed={elapsed}s "
                    f"< first_timeout={self._first_timeout_seconds}s + 0 reviews — "
                    f"Gemini external trigger 대기 (fast_path=true 시에만 owner trigger 허용)"
                ),
                elapsed_since_created_seconds=elapsed,
            )

        # 5) FIRST_GEMINI_TRIGGER_MISSING — FIRST_TIMEOUT_SECONDS 경과 + reviews 0 (회장 §3).
        # task-2563 §1 1:1: polling_policy.FIRST_TIMEOUT_SECONDS (1800s) 와 정확히 일치.
        if not snapshot.gemini_reviews:
            return IdlePRDiagnosis(
                state=STATE_FIRST_GEMINI_TRIGGER_MISSING,
                task_id=task_id,
                pr_number=snapshot.number,
                head_sha=head_sha,
                latest_gemini_commit_id=None,
                reason=(
                    f"elapsed={elapsed}s >= first_timeout={self._first_timeout_seconds}s + "
                    f"0 Gemini reviews — MISSING 확정, owner trigger required"
                ),
                elapsed_since_created_seconds=elapsed,
            )

        # 5) GEMINI_STALE_ON_HEAD — head_sha != latest commit_id (회장 §4).
        assert latest_commit_id is not None  # gemini_reviews 비어있지 않음 보장
        if latest_commit_id != head_sha:
            return IdlePRDiagnosis(
                state=STATE_GEMINI_STALE_ON_HEAD,
                task_id=task_id,
                pr_number=snapshot.number,
                head_sha=head_sha,
                latest_gemini_commit_id=latest_commit_id,
                reason=(
                    f"head_sha={head_sha[:8]} != latest_gemini_commit={latest_commit_id[:8]} "
                    f"— follow-up commit 후 stale, owner trigger required"
                ),
                elapsed_since_created_seconds=elapsed,
            )

        # 6) GEMINI_FRESH_ON_HEAD — head_sha == latest commit_id.
        return IdlePRDiagnosis(
            state=STATE_GEMINI_FRESH_ON_HEAD,
            task_id=task_id,
            pr_number=snapshot.number,
            head_sha=head_sha,
            latest_gemini_commit_id=latest_commit_id,
            reason=f"head_sha == latest_gemini_commit ({head_sha[:8]}) — ready for merge gate",
            elapsed_since_created_seconds=elapsed,
        )

    def diagnose_all(
        self,
        snapshots: Sequence[IdlePRSnapshot],
        *,
        now: str | None = None,
    ) -> list[IdlePRDiagnosis]:
        """여러 PR snapshot 을 일괄 진단. 동일한 ``now`` 가 모든 PR 에 적용된다.

        scheduler 는 본 메서드 반환 결과를 보고 ``requires_owner_trigger`` 인 PR 만
        owner trigger 호출 대상으로 골라낸다.
        """
        return [self.diagnose(snap, now=now) for snap in snapshots]


__all__ = [
    "STATE_WITHIN_GRACE_PERIOD",
    "STATE_FIRST_TRIGGER_PENDING",
    "STATE_FIRST_GEMINI_TRIGGER_MISSING",
    "STATE_GEMINI_STALE_ON_HEAD",
    "STATE_GEMINI_FRESH_ON_HEAD",
    "STATE_CI_FAILED",
    "STATE_MISSING_TASK_ID",
    "STATE_UNKNOWN",
    "STATE_OWNER_TRIGGER_REQUIRED",
    "ALL_STATES",
    "OWNER_TRIGGER_INVOKING_STATES",
    "GRACE_PERIOD_SECONDS",
    "FIRST_TRIGGER_PENDING_WINDOW_SECONDS",
    "GeminiReviewMeta",
    "IdlePRSnapshot",
    "IdlePRDiagnosis",
    "IdlePRDiagnoser",
    "extract_task_id",
    "elapsed_seconds_since",
]
