# -*- coding: utf-8 -*-
"""dispatch.anu_pickup_reporter — task-2732 P0B_MINIMUM_PICKUP reporting path.

driver(scan_once)가 result.json 을 수거·verdict 한 뒤, 그 verdict 를
**회장 chat(6937032012)** 에 ``PASS`` / ``HOLD`` / ``CALLBACK_MISSING`` 한 줄로
자동 보고한다. driver 는 수거·closeout·verdict 만 수행하고(launcher_fn=None,
wake/callback launch 0), 이 모듈은 그 결과를 보고만 한다 — **깨우기/relay 0**.

설계 불변식(SPEC-ANCHOR):
  - ANCHOR-1: wake/callback launch 0. 이 모듈은 wake-launcher / cron prompt / ANU relay
    를 호출하지 않는다. 보고는 **cokacdir --sendfile** (key 불요·agent wake 0)만 사용한다.
  - ANCHOR-2: verdict → 회장 PASS/HOLD/CALLBACK_MISSING. ANU/봇 key literal 노출 0
    (sealed loader 는 보고 경로에 불필요 — sendfile 은 key 없이 동작; argv 에 key 가
    실릴 경우를 대비한 redaction helper 만 제공).
  - canonical write 0: dedupe ledger·보고 임시파일은 memory/p0b_state/ (isolated state)에만
    기록한다. canonical events(memory/events) 변형 0.

reporting 매핑 (§4):
  - ``CLOSEOUT_DONE`` / ``VERDICT_AUTHORITATIVE`` → ``PASS``
    (단, result.json callback contract 위반/부재 신호 시 → ``CALLBACK_MISSING`` 우선).
  - ``QUARANTINE`` (owner-proof FAIL: NOT_ANU/QUERY_FAILED 포함) → ``HOLD``.
  - result.json ``callback_schedule_created=true``(contract 위반) 또는 callback 부재
    신호 → ``CALLBACK_MISSING``.
  - ``NOOP_*`` (disabled/not-target/not-ready/legacy/max-files-defer) → 보고 안 함.
  - 그 외(WAKE_BUILT/PICKUP_SKIP/FIRE_FAILED 등) → 보고 안 함(spec 매핑 밖).
  - 보고 1건당 1회 — task_id dedupe(ledger marker).
"""
from __future__ import annotations

import json
import os
import re
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Callable, List, Optional

# 재사용: 회장 chat id + allowlist-only sendfile(send_report). 이 import 로
# wake/relay 경로가 끌려오지 않는다(terminal_relay 는 git/gh/dispatch import 0).
from dispatch.anu_terminal_relay import ANU_CHAT_ID, send_report  # pyright: ignore[reportMissingImports]

# ── 상수 ─────────────────────────────────────────────────────────────────────
CANONICAL_ROOT = "/home/jay/workspace"

# 보고 라벨 (회장 보고 verdict)
REPORT_PASS = "PASS"
REPORT_HOLD = "HOLD"
REPORT_CALLBACK_MISSING = "CALLBACK_MISSING"

# driver verdict 문자열 (anu_pickup_driver 와 동일 — import 결합 회피 위해 라벨만 재선언)
DRIVER_CLOSEOUT_DONE = "CLOSEOUT_DONE"
DRIVER_QUARANTINE = "QUARANTINE"
# anu_owned_callback_enforcement.VERDICT_AUTHORITATIVE 라벨 (수거 권위 PASS)
VERDICT_AUTHORITATIVE = "AUTHORITATIVE"

# 보고 대상 = PASS 후보 verdict 집합(+ AUTHORITATIVE 권위 라벨).
_PASS_VERDICTS = (DRIVER_CLOSEOUT_DONE, VERDICT_AUTHORITATIVE)
_HOLD_VERDICTS = (DRIVER_QUARANTINE,)

# dedupe ledger / 보고 임시파일 — isolated p0b_state (canonical events 0 touch).
REPORTER_LEDGER_REL = "memory/p0b_state/reporter_sent.jsonl"
REPORTER_OUTBOX_REL = "memory/p0b_state/reporter_outbox"

RESULT_SUFFIX = ".result.json"

# argv redaction (로그/디버그에 key literal 0)
REDACTED = "***REDACTED***"
_REDACT_FLAGS = ("--key",)

KST = timezone(timedelta(hours=9))


# ── ReportDecision dataclass ──────────────────────────────────────────────────
@dataclass
class ReportDecision:
    task_id: str
    decision: Optional[str]   # PASS/HOLD/CALLBACK_MISSING or None(보고 안 함)
    verdict: str
    sha256: str = ""
    sent: bool = False
    deduped: bool = False
    error: Optional[str] = None

    def to_json(self) -> dict:
        return {
            "task_id": self.task_id,
            "decision": self.decision,
            "verdict": self.verdict,
            "sha256": self.sha256,
            "sent": self.sent,
            "deduped": self.deduped,
            "error": self.error,
        }


# ── argv redaction (key literal 노출 0) ───────────────────────────────────────
def redact_argv(argv) -> List[str]:
    """argv list 에서 ``--key`` 다음 토큰을 마스킹한 사본을 반환(원본 불변).
    로그/evidence 에 argv 를 남길 때 key literal 노출 0 을 보장하기 위한 helper."""
    if not isinstance(argv, (list, tuple)):
        return []
    out: List[str] = []
    redact_next = False
    for tok in argv:
        if redact_next:
            out.append(REDACTED)
            redact_next = False
            continue
        s = str(tok)
        out.append(s)
        if s in _REDACT_FLAGS:
            redact_next = True
        else:
            # --key=VALUE 형태도 마스킹
            for fl in _REDACT_FLAGS:
                if s.startswith(fl + "="):
                    out[-1] = fl + "=" + REDACTED
                    break
    return out


# ── verdict → 보고 매핑 (pure, 단위 test 대상) ────────────────────────────────
def _callback_missing(result_data: Optional[dict]) -> bool:
    """result.json 의 callback contract 위반/부재 신호 판정.

    - ``callback_schedule_created`` truthy → True (contract 위반: executor 가
      callback 을 self-schedule. task-2731 contract 상 false 가 정상).
    - ``callback_present`` 이 명시적으로 False → True (callback 부재 신호).
    - 그 외(부재/판단 불가) → False (보수적: PASS 를 CALLBACK_MISSING 으로 오인 0).
    """
    if not isinstance(result_data, dict):
        return False
    if bool(result_data.get("callback_schedule_created", False)):
        return True
    if "callback_present" in result_data and not bool(result_data.get("callback_present")):
        return True
    return False


def map_report(verdict, result_data: Optional[dict] = None) -> Optional[str]:
    """driver verdict(+optional result.json) → 회장 보고 라벨 매핑.

    반환: ``PASS`` / ``HOLD`` / ``CALLBACK_MISSING`` 또는 None(보고 안 함).
    NOOP_* 및 spec 매핑 밖 verdict(WAKE_BUILT/PICKUP_SKIP/FIRE_FAILED 등)는 None.
    """
    if verdict is None:
        return None
    v = str(verdict).strip()
    if not v:
        return None
    if v.startswith("NOOP_"):
        return None
    if v in _PASS_VERDICTS:
        # callback contract 위반/부재가 있으면 PASS 보다 CALLBACK_MISSING 우선.
        if _callback_missing(result_data):
            return REPORT_CALLBACK_MISSING
        return REPORT_PASS
    if v in _HOLD_VERDICTS:
        return REPORT_HOLD
    return None


# ── helper: task_id / sha256 / 보고 한 줄 ─────────────────────────────────────
def task_id_from_path(path: str) -> str:
    """``.../task-2732.result.json`` → ``task-2732``. 부적합 시 빈 문자열."""
    if not path:
        return ""
    base = os.path.basename(str(path))
    if base.endswith(RESULT_SUFFIX):
        return base[: -len(RESULT_SUFFIX)]
    return ""


def _short_sha(sha256: str) -> str:
    s = str(sha256 or "").strip()
    return s[:12] if s else "-"


def build_report_line(task_id: str, decision: str, verdict: str, sha256: str = "") -> str:
    """회장 보고 한 줄: ``task-X: PASS|HOLD|CALLBACK_MISSING · <verdict> · <sha256>``."""
    return f"{task_id}: {decision} · {verdict} · {_short_sha(sha256)}"


# ── dedupe ledger (task_id 1회 보고) ──────────────────────────────────────────
def _ledger_path(root: str, ledger_path: Optional[str]) -> str:
    return ledger_path or os.path.join(root, REPORTER_LEDGER_REL)


def already_reported(task_id: str, *, root: str = CANONICAL_ROOT,
                     ledger_path: Optional[str] = None) -> bool:
    """ledger 에 동일 task_id 보고 기록이 있으면 True(보고 1건당 1회 dedupe)."""
    ledger = _ledger_path(root, ledger_path)
    if not task_id or not os.path.isfile(ledger):
        return False
    try:
        with open(ledger, "r", encoding="utf-8") as fh:
            for line in fh:
                line = line.strip()
                if not line:
                    continue
                try:
                    entry = json.loads(line)
                except (ValueError, TypeError):
                    continue
                if isinstance(entry, dict) and entry.get("task_id") == task_id:
                    return True
    except OSError:
        return False
    return False


def _mark_reported(task_id: str, decision: str, sha256: str, *, root: str,
                   ledger_path: Optional[str], clock) -> Optional[str]:
    """보고 완료 1줄 append(durable: makedirs+fsync). key literal 미기록.

    반환: 성공 시 None, 실패 시 에러 메시지(str). ★ 실패를 silently swallow 하지 않고
    호출자(report_one)에 전달하여 ReportDecision.error 로 가시화한다. dedupe ledger
    내구성 실패(디스크 IO)는 드물지만, 발생 시 다음 cycle 재전송(중복) 가능성을 명시적으로
    드러낸다. (send-first 순서: 회장 보고 누락 회피 > 드문 중복 — 누락이 더 해롭다.)"""
    ledger = _ledger_path(root, ledger_path)
    rec = {
        "ts": clock().isoformat(),
        "task_id": task_id,
        "decision": decision,
        "sha256": str(sha256 or ""),
    }
    try:
        os.makedirs(os.path.dirname(ledger) or ".", exist_ok=True)
        with open(ledger, "a", encoding="utf-8") as fh:
            fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
            fh.flush()
            os.fsync(fh.fileno())
        return None
    except OSError as exc:
        return f"dedupe ledger 미기록(durability) — 다음 cycle 재전송 가능: {exc}"


# ── 보고 임시파일 작성(isolated state) ────────────────────────────────────────
def _write_report_file(task_id: str, line: str, *, root: str,
                       outbox_dir: Optional[str]) -> Optional[str]:
    """보고 한 줄을 isolated outbox 파일로 작성하고 경로 반환(실패 시 None).
    canonical events 0 touch — memory/p0b_state/reporter_outbox 하위에만 기록."""
    odir = outbox_dir or os.path.join(root, REPORTER_OUTBOX_REL)
    safe = re.sub(r"[^A-Za-z0-9._-]", "_", task_id) or "task"
    fname = f"{safe}.{int(time.time() * 1000)}.report.txt"
    fpath = os.path.join(odir, fname)
    try:
        os.makedirs(odir, exist_ok=True)
        with open(fpath, "w", encoding="utf-8") as fh:
            fh.write(line + "\n")
            fh.flush()
            os.fsync(fh.fileno())
        return fpath
    except OSError:
        return None


# processed 디렉토리 suffix-변형 탐색 시 1회 scan 당 검사 상한(bounded scan).
# 디렉토리가 비정상적으로 커져도(처리 누적) reporter cycle 당 listdir 폭주 0.
PROCESSED_SCAN_LIMIT = 256


def _try_load_json_dict(path: str) -> Optional[dict]:
    """path 를 JSON dict 로 적재. 실패/비-dict → None(listdir 없이 직접 시도용)."""
    try:
        with open(path, "rb") as fh:
            raw = fh.read()
        data = json.loads(raw.decode("utf-8"))
    except (OSError, ValueError, UnicodeDecodeError):
        return None
    return data if isinstance(data, dict) else None


# ── 기본 result_loader (CLOSEOUT 후 processed 이동 대응) ──────────────────────
def _default_result_loader(record_path: str, *, root: str) -> dict:
    """result_path → result dict. 원위치 부재 시 processed 디렉토리에서 basename 매칭.
    읽기/파싱 실패 시 {} 반환(보수적: callback 판정 불가 → PASS 보존).

    ★ (c) MEDIUM fix: processed dir 전체를 **무조건** ``os.listdir`` 하던 scan 을 제거.
      1) 직접 후보(원위치 record_path · processed/<basename>)를 listdir 없이 먼저 시도
         — 정상 경로에서 디렉토리 scan 0.
      2) 직접 후보가 모두 부재할 때에만 충돌-회피 suffix(``.<ms>[-n]``) 변형을
         ``os.scandir`` + ``PROCESSED_SCAN_LIMIT`` 상한 bounded scan 으로 탐색
         (전체 무조건 scan·무제한 누적 폭주 회피)."""
    if not record_path:
        return {}
    base = os.path.basename(record_path)
    pdir = os.path.join(root, "memory", "p0b_state", "processed")
    # 1) 직접 후보 — listdir 없이 시도(정상 경로: 디렉토리 scan 0).
    for c in (record_path, os.path.join(pdir, base)):
        data = _try_load_json_dict(c)
        if data is not None:
            return data
    # 2) 직접 후보 부재 시에만 suffix 변형을 bounded scan(상한 PROCESSED_SCAN_LIMIT).
    prefix = base + "."
    try:
        with os.scandir(pdir) as it:
            scanned = 0
            for ent in it:
                if scanned >= PROCESSED_SCAN_LIMIT:
                    break
                scanned += 1
                if ent.name.startswith(prefix):
                    data = _try_load_json_dict(os.path.join(pdir, ent.name))
                    if data is not None:
                        return data
    except OSError:
        pass
    return {}


# ── 기본 sender (cokacdir --sendfile, key 불요·wake 0) ────────────────────────
def _default_sender(file_path: str, *, chat_id: str) -> bool:
    """allowlist-only sendfile 로 회장 chat 에 보고 파일 전송. 성공 여부 bool.

    ★ wake 0: --sendfile 만 사용(cron prompt/agent wake/relay 0). key literal 0:
    sendfile 은 key 불요. send_report 는 shell=False·고정 argv·화이트리스트 검증.

    ★ (a) HIGH fix: send 성공은 **실제 전송 성공이 확인된 경우에만** True.
      - 예외 발생 → 실패(False). 다음 cycle 재시도(ledger 미기록).
      - 결과가 None 이거나 ``returncode`` 속성/값이 없으면(계약 위반·예기치 못한 반환)
        **성공 간주 0** → 실패(False). (이전: ``getattr(res, "returncode", 0)`` 가
        None/속성부재를 0 으로 오인하여 미전송을 성공으로 기록하던 HIGH 결함을 차단.)
      - ``returncode == 0`` 이 명시적으로 확인된 경우에만 True."""
    try:
        res = send_report(file_path, chat_id=chat_id)
    except Exception:  # noqa: BLE001 — 전송 실패는 비치명(fail-safe). 다음 cycle 재시도.
        return False
    if res is None:
        return False
    rc = getattr(res, "returncode", None)
    if not isinstance(rc, int):
        # returncode 속성 부재 또는 비정수(미확정) → 성공 간주 금지.
        return False
    return rc == 0


# ── 단일 record 보고 ──────────────────────────────────────────────────────────
def report_one(
    verdict: Optional[str],
    result_path: str,
    sha256: str = "",
    *,
    root: str = CANONICAL_ROOT,
    sender: Optional[Callable] = None,
    result_loader: Optional[Callable] = None,
    result_data: Optional[dict] = None,
    chat_id: str = ANU_CHAT_ID,
    ledger_path: Optional[str] = None,
    outbox_dir: Optional[str] = None,
    clock: Optional[Callable] = None,
) -> ReportDecision:
    """단일 verdict 를 매핑→dedupe→보고. ReportDecision 반환."""
    clock = clock or (lambda: datetime.now(KST))
    task_id = task_id_from_path(result_path) or str(result_path or "").strip()

    # result.json 적재 (callback 판정용). 명시 주입 우선, 없으면 loader.
    if result_data is None:
        loader = result_loader or (lambda p: _default_result_loader(p, root=root))
        try:
            result_data = loader(result_path)
        except Exception:  # noqa: BLE001
            result_data = {}

    # sha256 미제공 시 result.json 에서 보강(보고 한 줄 fidelity).
    if not str(sha256 or "").strip() and isinstance(result_data, dict):
        sha256 = str(result_data.get("sha256") or "")

    decision = map_report(verdict, result_data)
    if decision is None:
        return ReportDecision(task_id=task_id, decision=None, verdict=str(verdict),
                              sha256=str(sha256 or ""))

    if not task_id:
        return ReportDecision(task_id="", decision=decision, verdict=str(verdict),
                              sha256=str(sha256 or ""), error="task_id 미해석 — 보고 skip")

    # dedupe: 보고 1건당 1회.
    if already_reported(task_id, root=root, ledger_path=ledger_path):
        return ReportDecision(task_id=task_id, decision=decision, verdict=str(verdict),
                              sha256=str(sha256 or ""), deduped=True)

    line = build_report_line(task_id, decision, str(verdict), sha256)
    fpath = _write_report_file(task_id, line, root=root, outbox_dir=outbox_dir)
    if fpath is None:
        return ReportDecision(task_id=task_id, decision=decision, verdict=str(verdict),
                              sha256=str(sha256 or ""), error="보고 파일 작성 실패")

    snd = sender or (lambda fp: _default_sender(fp, chat_id=chat_id))
    try:
        ok = bool(snd(fpath))
    except Exception as exc:  # noqa: BLE001
        return ReportDecision(task_id=task_id, decision=decision, verdict=str(verdict),
                              sha256=str(sha256 or ""), error=f"sender 예외: {exc}")
    if not ok:
        # 전송 실패 → ledger 미기록(다음 cycle 재시도). 중복 보고 0 유지.
        return ReportDecision(task_id=task_id, decision=decision, verdict=str(verdict),
                              sha256=str(sha256 or ""), error="전송 실패(returncode!=0)")

    # send-first 순서: 메시지 전송 성공 후 dedupe ledger 기록. ledger 기록 실패는
    # silently swallow 하지 않고 ReportDecision.error 로 가시화(드문 IO 실패 시 다음
    # cycle 재전송 가능성을 명시). 회장 보고 누락 회피를 중복보다 우선(누락이 더 해롭다).
    mark_err = _mark_reported(task_id, decision, sha256, root=root,
                              ledger_path=ledger_path, clock=clock)
    return ReportDecision(task_id=task_id, decision=decision, verdict=str(verdict),
                          sha256=str(sha256 or ""), sent=True, error=mark_err)


# ── records 일괄 보고 (driver main 결선 진입점) ───────────────────────────────
def _record_field(record, name: str):
    """DriverRecord(dataclass) 또는 dict 모두에서 field 추출."""
    if isinstance(record, dict):
        return record.get(name)
    return getattr(record, name, None)


def report_records(
    records: list,
    *,
    root: str = CANONICAL_ROOT,
    sender: Optional[Callable] = None,
    result_loader: Optional[Callable] = None,
    chat_id: str = ANU_CHAT_ID,
    ledger_path: Optional[str] = None,
    outbox_dir: Optional[str] = None,
    clock: Optional[Callable] = None,
) -> List[ReportDecision]:
    """scan_once 가 반환한 DriverRecord 목록을 순회하며 보고. 결정 목록 반환.

    각 record: verdict + result_path. sha256 은 record(없으면 result.json)에서 추출.
    NOOP_*/매핑 밖 → 보고 안 함. PASS/HOLD/CALLBACK_MISSING → dedupe 후 1회 전송.
    """
    out: List[ReportDecision] = []
    for rec in records or []:
        verdict = _record_field(rec, "verdict")
        result_path = _record_field(rec, "result_path") or ""
        sha256 = _record_field(rec, "sha256") or ""
        out.append(report_one(
            verdict,
            result_path,
            sha256,
            root=root,
            sender=sender,
            result_loader=result_loader,
            chat_id=chat_id,
            ledger_path=ledger_path,
            outbox_dir=outbox_dir,
            clock=clock,
        ))
    return out


__all__ = [
    "CANONICAL_ROOT",
    "REPORT_PASS",
    "REPORT_HOLD",
    "REPORT_CALLBACK_MISSING",
    "VERDICT_AUTHORITATIVE",
    "REPORTER_LEDGER_REL",
    "REPORTER_OUTBOX_REL",
    "PROCESSED_SCAN_LIMIT",
    "REDACTED",
    "KST",
    "ReportDecision",
    "redact_argv",
    "map_report",
    "task_id_from_path",
    "build_report_line",
    "already_reported",
    "report_one",
    "report_records",
]
