# -*- coding: utf-8 -*-
"""anu_v3.active_dispatch_scanner — read-only active dispatch enumerator.

task-2553+31 ANU_RUNTIME_RECONCILE_CHECKPOINT leaf module
(구현목표 1·2 — active dispatch 목록 / dispatch log status 감지).

Two read-only sources:
  * normalized reconcile fixture (memory/fixtures/*.json) — primary input for
    deterministic regression
  * *.dispatch-fired.json event files (read/parse only) — live enumeration

NO-CRON / read-only invariant (§7/§9/§10): zero cron register/remove, zero
schedule mutation, zero write. cron-list / schedule_history are *read-only*
inputs when consulted; this module never cancels/removes/registers a cron.
"""
from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional


@dataclass(frozen=True)
class DispatchRecord:
    """Read-only view of one dispatched track's 4-tuple + status."""

    task_id: str
    dispatch_cron_id: str
    dispatch_status: str
    executor: str = ""
    fallback_callback_cron_id: str = ""
    expected_normal_collector_cron_id: Optional[str] = None
    normal_collector_cron_id: Optional[str] = None
    by_design_no_normal_collector: bool = False
    expected_artifacts: List[str] = field(default_factory=list)

    @property
    def dispatch_ok(self) -> bool:
        return str(self.dispatch_status).lower() in ("ok", "fired", "success")

    def four_tuple(self) -> Dict[str, Optional[str]]:
        return {
            "task_id": self.task_id,
            "dispatch_cron_id": self.dispatch_cron_id,
            "normal_collector_cron_id": (
                self.normal_collector_cron_id
                or self.expected_normal_collector_cron_id
            ),
            "fallback_callback_cron_id": self.fallback_callback_cron_id,
        }

    def to_json(self) -> Dict[str, object]:
        return {
            "task_id": self.task_id,
            "dispatch_cron_id": self.dispatch_cron_id,
            "dispatch_status": self.dispatch_status,
            "dispatch_ok": self.dispatch_ok,
            "executor": self.executor,
            "fallback_callback_cron_id": self.fallback_callback_cron_id,
            "expected_normal_collector_cron_id":
                self.expected_normal_collector_cron_id,
            "by_design_no_normal_collector":
                self.by_design_no_normal_collector,
            "expected_artifacts": list(self.expected_artifacts),
        }


class ActiveDispatchScanner:
    """Enumerate active dispatch tracks read-only."""

    def __init__(self, repo_root: Path,
                 events_subdir: str = "memory/events") -> None:
        self.repo_root = Path(repo_root)
        self.events_dir = self.repo_root / events_subdir

    # ---- fixture-driven (deterministic) ---------------------------------
    @staticmethod
    def from_fixture(fixture_path: Path) -> List[DispatchRecord]:
        """Parse a normalized reconcile fixture (read-only).

        Accepts both the flat ``{"tracks": [...]}`` shape and a bare list.
        """
        doc = json.loads(Path(fixture_path).read_text(encoding="utf-8"))
        tracks = doc.get("tracks", doc) if isinstance(doc, dict) else doc
        out: List[DispatchRecord] = []
        for t in tracks:
            out.append(
                DispatchRecord(
                    task_id=t["task_id"],
                    dispatch_cron_id=t.get("dispatch_cron_id", ""),
                    dispatch_status=t.get("dispatch_status", "ok"),
                    executor=t.get("executor", ""),
                    fallback_callback_cron_id=t.get(
                        "fallback_callback_cron_id", ""),
                    expected_normal_collector_cron_id=t.get(
                        "expected_normal_collector_cron_id"),
                    normal_collector_cron_id=t.get(
                        "normal_collector_cron_id"),
                    by_design_no_normal_collector=bool(
                        t.get("by_design_no_normal_collector", False)),
                    expected_artifacts=list(
                        t.get("expected_artifacts", [])),
                )
            )
        return out

    # ---- live event-file scan (read/parse only) -------------------------
    def scan_dispatch_fired(
        self, task_id_prefix: str = ""
    ) -> List[DispatchRecord]:
        """Enumerate *.dispatch-fired.json under events/ (read-only).

        Pure stat + json parse. No file is created or modified.
        """
        out: List[DispatchRecord] = []
        if not self.events_dir.is_dir():
            return out
        for p in sorted(self.events_dir.glob("*.dispatch-fired.json")):
            try:
                doc = json.loads(p.read_text(encoding="utf-8"))
            except (OSError, ValueError):
                continue
            tid = doc.get("task_id", "")
            if task_id_prefix and not tid.startswith(task_id_prefix):
                continue
            dispatch = doc.get("dispatch", {}) or {}
            cb = doc.get("callback_policy_a", {}) or {}
            ft = cb.get("four_tuple", {}) or {}
            out.append(
                DispatchRecord(
                    task_id=tid,
                    dispatch_cron_id=dispatch.get("cron_id", ""),
                    dispatch_status=doc.get("dispatch_status", "ok"),
                    executor=dispatch.get("executor", ""),
                    fallback_callback_cron_id=cb.get(
                        "fallback_callback_cron_id",
                        ft.get("fallback_callback_cron_id", "")),
                    expected_normal_collector_cron_id=ft.get(
                        "normal_collector_cron_id"),
                )
            )
        return out
