#!/usr/bin/env python3
"""check_cron_fire_outcome.py — cokacdir cron schedule의 fire outcome을 executable로 판정.

TASK2750: 사람이 cron.log/schedule_history를 눈으로 해석해 "발사됐나/busy skip인가/misfire인가"
판정하던 것을 결정론 checker로 대체한다. read-only — schedule store/cron.log/schedule_history/
cron-history/workspace를 읽기만 한다(write/cron/flag/daemon 변경 0). raw key/verifier 미출력.

근거: v5 recanary에서 ANU-owned callback cron(C7B5018F)이 `should execute`였으나 chat busy로
daemon이 skip → spawn 0. 이를 FIRED/misfire와 구분해야 normal callback 성공을 오판하지 않는다.

★ TASK2750-r2 견고성(Gemini findings): timezone을 KST aware로 명시 정규화(naive→서버추정 금지),
   schedule_id는 단순 substring이 아니라 word-boundary exact match(false-positive 방지).

출력 enum (7):
  FIRED_AND_SPAWNED      — schedule_history/cron-history fire record + spawned workspace 존재
  FIRED_NO_SPAWN_PROOF   — fire record 있으나 spawned workspace 없음
  BUSY_SKIP_ALREADY_AWAKE— cron.log에 should execute + chat busy/already pending skip
  PENDING_NOT_DUE        — now < expected_fire_at (아직 due 아님)
  DUE_BUT_NOT_FIRED      — due 지났고 schedule 존재하나 fire/skip 기록 없음
  REMOVED_BEFORE_FIRE    — schedule 제거됨 + fire proof 0
  MISFIRE_UNKNOWN        — 위 어디에도 안 맞음(추가 조사 필요)
"""
from __future__ import annotations

import argparse
import glob
import json
import os
import re
from datetime import datetime, timezone, timedelta
from typing import Optional

HOME = os.path.expanduser("~")
DEFAULT_STORE = os.path.join(HOME, ".cokacdir", "schedule")
DEFAULT_HISTORY = os.path.join(HOME, ".cokacdir", "schedule_history")
DEFAULT_WORKSPACE = os.path.join(HOME, ".cokacdir", "workspace")
DEFAULT_CRON_LOG = os.path.join(HOME, ".cokacdir", "debug", "cron.log")

# ★ cokacdir schedule 문자열("2026-06-14 23:52:32")은 KST naive. 서버 tz 추정 대신 KST 명시.
KST = timezone(timedelta(hours=9))

OUTCOMES = (
    "FIRED_AND_SPAWNED",
    "FIRED_NO_SPAWN_PROOF",
    "BUSY_SKIP_ALREADY_AWAKE",
    "PENDING_NOT_DUE",
    "DUE_BUT_NOT_FIRED",
    "REMOVED_BEFORE_FIRE",
    "MISFIRE_UNKNOWN",
)


def _parse_fire_at(s: str) -> Optional[float]:
    """schedule 문자열 → epoch(UTC). tz 명시(+09:00 등)면 그대로, naive면 **KST aware**로 정규화.

    서버 로컬 tz 추정(naive datetime의 .timestamp())을 쓰지 않는다 — 다른 tz 환경에서 오판 방지.
    """
    if not s:
        return None
    raw = s.strip()
    # 1) ISO offset/tz 명시("2026-06-14T23:52:32+09:00", "...Z") → aware 그대로
    iso = raw.replace(" ", "T", 1) if ("T" not in raw and " " in raw) else raw
    iso_z = iso[:-1] + "+00:00" if iso.endswith("Z") else iso
    try:
        dt = datetime.fromisoformat(iso_z)
        if dt.tzinfo is not None:
            return dt.timestamp()
        return dt.replace(tzinfo=KST).timestamp()  # naive → KST 명시
    except ValueError:
        pass
    # 2) fromisoformat 실패 시 strptime(초 단위)로 naive 파싱 후 KST 명시
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
        try:
            dt = datetime.strptime(raw[:19], fmt)
            return dt.replace(tzinfo=KST).timestamp()
        except ValueError:
            continue
    return None


def _id_match(line: str, sid: str) -> bool:
    """cron.log 라인에 schedule_id가 **정확히** 등장하는지(앞뒤 영숫자 경계). 단순 substring 금지.

    cokacdir cron.log 패턴: 'id=C7B5018F,', '/C7B5018F.json', 'id=C7B5018F ' 등.
    sid 앞뒤가 영숫자가 아니어야 매칭(다른 id의 부분문자열 false-match 방지).
    """
    return re.search(r"(?<![0-9A-Za-z])" + re.escape(sid) + r"(?![0-9A-Za-z])", line) is not None


def _read_schedule_file(store: str, sid: str) -> Optional[dict]:
    p = os.path.join(store, f"{sid}.json")
    if not os.path.exists(p):
        return None
    try:
        with open(p, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None


def _cron_log_signals(cron_log: str, sid: str) -> dict:
    """cron.log에서 sid 관련 신호 추출(read-only, exact id match, 키/verifier 미수집)."""
    sig = {"appears": False, "should_execute": False, "chat_busy_skip": False,
           "executing": False}
    paths = [cron_log] + sorted(glob.glob(cron_log + ".[0-9]"))  # rotated 포함
    for path in paths:
        if not os.path.exists(path):
            continue
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as f:
                for line in f:
                    if not _id_match(line, sid):
                        continue
                    sig["appears"] = True
                    low = line.lower()
                    if "should execute" in low:
                        sig["should_execute"] = True
                    if ("chat busy" in low) or ("already pending" in low and "skip" in low):
                        sig["chat_busy_skip"] = True
                    if "executing" in low or "spawn" in low or "running" in low:
                        sig["executing"] = True
        except Exception:
            continue
    return sig


def _history_fire_record(history_dir: str, sid: str, chat_id: Optional[int]) -> bool:
    p = os.path.join(history_dir, f"{sid}.log")
    if not os.path.exists(p):
        return False
    try:
        with open(p, "r", encoding="utf-8", errors="replace") as f:
            for line in f:
                if not line.strip():
                    continue
                try:
                    d = json.loads(line)
                except Exception:
                    continue
                if chat_id is not None and str(d.get("chat_id")) != str(chat_id):
                    continue
                if d.get("ts") or d.get("status"):
                    return True
    except Exception:
        return False
    return False


def _spawned_workspace(workspace_root: str, sid: str) -> bool:
    return os.path.isdir(os.path.join(workspace_root, sid))


def classify(*, schedule_id: str, chat_id: Optional[int] = None,
             expected_fire_at: Optional[str] = None, now: Optional[float] = None,
             store: str = DEFAULT_STORE, history_dir: str = DEFAULT_HISTORY,
             workspace_root: str = DEFAULT_WORKSPACE,
             cron_log: str = DEFAULT_CRON_LOG) -> dict:
    # now: epoch(UTC). 미지정 시 KST aware now → epoch(tz-safe).
    now = now if now is not None else datetime.now(KST).timestamp()
    sched = _read_schedule_file(store, schedule_id)
    sig = _cron_log_signals(cron_log, schedule_id)
    fired_record = _history_fire_record(history_dir, schedule_id, chat_id)
    spawned = _spawned_workspace(workspace_root, schedule_id)
    fire_at = _parse_fire_at(expected_fire_at) if expected_fire_at else (
        _parse_fire_at(str(sched.get("schedule"))) if sched else None)

    # ── 판정 우선순위 ───────────────────────────────────────────────
    if fired_record or spawned:
        outcome = "FIRED_AND_SPAWNED" if spawned else "FIRED_NO_SPAWN_PROOF"
    elif sig["should_execute"] and sig["chat_busy_skip"]:
        outcome = "BUSY_SKIP_ALREADY_AWAKE"
    elif fire_at is not None and now < fire_at:
        outcome = "PENDING_NOT_DUE"
    elif sched is None:
        outcome = "REMOVED_BEFORE_FIRE"
    elif fire_at is not None and now >= fire_at:
        outcome = "DUE_BUT_NOT_FIRED"
    else:
        outcome = "MISFIRE_UNKNOWN"

    return {
        "schedule_id": schedule_id,
        "outcome": outcome,
        "evidence": {
            "schedule_file_present": sched is not None,
            "schedule_chat_id": sched.get("chat_id") if sched else None,
            "schedule_str": sched.get("schedule") if sched else None,
            "fire_at_epoch": fire_at,
            "now_epoch": now,
            "due": (fire_at is not None and now >= fire_at),
            "cron_log_appears": sig["appears"],
            "cron_log_should_execute": sig["should_execute"],
            "cron_log_chat_busy_skip": sig["chat_busy_skip"],
            "history_fire_record": fired_record,
            "spawned_workspace": spawned,
        },
        # ★ raw key/bot_key_verifier 미포함(보안)
    }


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(prog="check_cron_fire_outcome")
    ap.add_argument("--schedule-id", required=True)
    ap.add_argument("--chat-id", type=int, default=None)
    ap.add_argument("--expected-fire-at", default=None,
                    help="'YYYY-MM-DD HH:MM:SS'(KST naive) 또는 ISO offset. 미지정 시 schedule file.")
    ap.add_argument("--now-epoch", type=float, default=None)
    ap.add_argument("--store", default=DEFAULT_STORE)
    ap.add_argument("--history-dir", default=DEFAULT_HISTORY)
    ap.add_argument("--workspace-root", default=DEFAULT_WORKSPACE)
    ap.add_argument("--cron-log", default=DEFAULT_CRON_LOG)
    a = ap.parse_args(argv)
    res = classify(schedule_id=a.schedule_id, chat_id=a.chat_id,
                   expected_fire_at=a.expected_fire_at, now=a.now_epoch,
                   store=a.store, history_dir=a.history_dir,
                   workspace_root=a.workspace_root, cron_log=a.cron_log)
    print(json.dumps(res, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
